Compare commits
96 Commits
task-galax
...
auto/drive
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a071b6d5a | |||
|
|
931049b5a7 | ||
| fa2fbb404d | |||
|
|
17faf76ea7 | ||
| 5432c49364 | |||
|
|
d7633fe36f | ||
| 69d9a6fbb5 | |||
|
|
07abee5f6d | ||
| 0f3abed4c7 | |||
|
|
cc21281cbb | ||
| 5e164dc965 | |||
|
|
02d1c85190 | ||
| 1d3e9a3237 | |||
|
|
0f509fbd3a | ||
| e879b3ae90 | |||
|
|
4d3ee47235 | ||
| 9ebe5bd523 | |||
|
|
63099115bf | ||
| 7042b11f34 | |||
|
|
2f3eeecd17 | ||
| 3b82f4f5fb | |||
|
|
451b37a632 | ||
| 6743d51db8 | |||
|
|
0044603902 | ||
| 2fc71d288e | |||
|
|
286ab3ba41 | ||
| 5ca2ad83cd | |||
|
|
e3c0750f7d | ||
| 177d75784b | |||
|
|
6e244e0c01 | ||
| 27878d0faf | |||
|
|
08d8a104bb | ||
| 7ee0cbc3f4 | |||
|
|
e5299cda5a | ||
| e5b192fcb3 | |||
|
|
cfcaf5c1d3 | ||
| 2731318c81 | |||
|
|
86407e6ca2 | ||
| 2266dd9ad5 | |||
|
|
0df14ab94a | ||
| 448a97d67f | |||
|
|
b699052324 | ||
| e6a55add20 | |||
|
|
fcf89618cd | ||
| f83c467647 | |||
|
|
80b2d7f8c3 | ||
| 8286255ae5 | |||
|
|
615ab25680 | ||
| 545cc74ec8 | |||
|
|
e5122c546b | ||
| 6737edbad2 | |||
|
|
ce98c2ada3 | ||
| 676eebd5e4 | |||
|
|
2b66cec582 | ||
| b751c1c096 | |||
|
|
316f820eff | ||
| 38eb909f69 | |||
|
|
d1699af609 | ||
| c6c694b69e | |||
|
|
4a3860ae92 | ||
| d57e24a7fa | |||
|
|
bb1ab47b68 | ||
| a04ba2af7a | |||
|
|
494fdf2358 | ||
| 9f1e033e83 | |||
|
|
fae00749ca | ||
| bf200e813e | |||
|
|
7209364c35 | ||
| 8314c273e7 | |||
|
|
1abf743a9f | ||
| 63a79791cd | |||
|
|
cc757855e6 | ||
| 84913638b1 | |||
|
|
9ec92a9082 | ||
| 49fc23adc6 | |||
|
|
3c2c4f29ea | ||
| ae7cc15178 | |||
|
|
3d9697b918 | ||
| 329e222aa2 | |||
|
|
551494d223 | ||
| 5b4925e61a | |||
|
|
4ff4cc5899 | ||
| b95eaacc05 | |||
|
|
c89f5bb3b9 | ||
| 07235d3b66 | |||
|
|
f2bc36349e | ||
| ccf2e3a9c0 | |||
|
|
8f7265186d | ||
| 651d6c005c | |||
|
|
36b2929780 | ||
| 345ac97c43 | |||
|
|
767ac4aec5 | ||
| 29edd835a3 | |||
|
|
d78a471e90 | ||
| 1d9e40236b | |||
|
|
2e6228a243 |
@@ -125,6 +125,35 @@ back an `IAlarmSource`, but shipping that is a separate feature.
|
||||
| "Do notifications coalesce under load?" | no | yes (required) |
|
||||
| "Does a TC2 PLC work the same as TC3?" | no | yes (required) |
|
||||
|
||||
## Performance
|
||||
|
||||
PR 2.1 (Sum-read / Sum-write, IndexGroup `0xF080..0xF084`) replaced the per-tag
|
||||
`ReadValueAsync` loop in `TwinCATDriver.ReadAsync` / `WriteAsync` with a
|
||||
bucketed bulk dispatch — N tags addressed against the same device flow through a
|
||||
single ADS sum-command round-trip via `SumInstancePathAnyTypeRead` (read) and
|
||||
`SumWriteBySymbolPath` (write). Whole-array tags + bit-extracted BOOL tags
|
||||
remain on the per-tag fallback path because the sum surface only marshals
|
||||
scalars and bit-RMW writes need the per-parent serialisation lock.
|
||||
|
||||
**Baseline → Sum-command delta** (dev box, 1000 × DINT, XAR VM over LAN):
|
||||
|
||||
| Path | Round-trips | Wall-clock |
|
||||
| --- | --- | --- |
|
||||
| Per-tag loop (pre-PR 2.1) | 1000 | ~5–8 s |
|
||||
| Sum-command bulk (PR 2.1) | 1 | ~250–600 ms |
|
||||
| Ratio | — | ≥ 10× typical, ≥ 5× CI floor |
|
||||
|
||||
The perf-tier test
|
||||
`TwinCATSumCommandPerfTests.Driver_sum_read_1000_tags_beats_loop_baseline_by_5x`
|
||||
asserts the ratio with a conservative 5× lower bound that survives noisy CI /
|
||||
VM scheduling. It is gated behind both `TWINCAT_TARGET_NETID` (XAR reachable)
|
||||
and `TWINCAT_PERF=1` (operator opt-in) — perf runs aren't part of the default
|
||||
integration pass because they hit the wire heavily.
|
||||
|
||||
The required fixture state (1000-DINT GVL + churn POU) is documented in
|
||||
`TwinCatProject/README.md §Performance scenarios`; XAE-form sources land at
|
||||
`TwinCatProject/PLC/GVLs/GVL_Perf.TcGVL` + `TwinCatProject/PLC/POUs/FB_PerfChurn.TcPOU`.
|
||||
|
||||
## Follow-up candidates
|
||||
|
||||
1. **XAR VM live-population** — scaffolding is in place (this PR); the
|
||||
|
||||
@@ -450,6 +450,104 @@ Test names:
|
||||
- **ET 200SP CPU (1510SP / 1512SP)**: behaves as S7-1500 from `MB_SERVER`
|
||||
perspective. No known deltas [3].
|
||||
|
||||
## Performance (native S7comm driver)
|
||||
|
||||
This section covers the native S7comm driver (`ZB.MOM.WW.OtOpcUa.Driver.S7`),
|
||||
not the Modbus-on-S7 quirks above. Both share a CPU but use different ports,
|
||||
different libraries, and different optimization levers.
|
||||
|
||||
### Block-read coalescing
|
||||
|
||||
The S7 driver runs a coalescing planner before every read pass: same-area /
|
||||
same-DB tags are sorted by byte offset and merged into single
|
||||
`Plc.ReadBytesAsync` requests when the gap between them is small. Reading
|
||||
`DB1.DBW0`, `DB1.DBW2`, `DB1.DBW4` issues **one** 6-byte byte-range read
|
||||
covering offsets 0..6, sliced client-side instead of three multi-var items
|
||||
(let alone three individual `Plc.ReadAsync` round-trips). On a 50-tag
|
||||
contiguous workload this reduces wire traffic from 50 single reads (or 3
|
||||
multi-var batches at the 19-item PDU ceiling) to **1 byte-range PDU**.
|
||||
|
||||
#### Default 16-byte gap-merge threshold
|
||||
|
||||
The planner merges two adjacent ranges when the gap between them is at most
|
||||
16 bytes. The default reflects the cost arithmetic on a 240-byte default
|
||||
PDU: an S7 request frame is ~30 bytes and a per-item response header is
|
||||
~12 bytes, so over-fetching 16 bytes (which decode-time discards) is
|
||||
cheaper than paying for one extra PDU round-trip.
|
||||
|
||||
The math also holds for 480/960-byte PDUs but the relative cost flips —
|
||||
on a 960-byte PDU you can fit a much larger request and the over-fetch
|
||||
ceiling is less of a concern. Sites running the extended PDU on S7-1500
|
||||
can safely raise the threshold (see operator guidance below).
|
||||
|
||||
#### Opaque-size opt-out for STRING / array / structured-timestamp tags
|
||||
|
||||
Variable-width and header-prefixed tag types **never** participate in
|
||||
coalescing:
|
||||
|
||||
- **STRING / WSTRING** carry a 2-byte (or 4-byte) length header, and the
|
||||
per-tag width depends on the configured `StringLength`.
|
||||
- **CHAR / WCHAR** are routed through the dedicated `S7StringCodec` decode
|
||||
path, which expects an exact byte slice, not an offset into a larger
|
||||
buffer.
|
||||
- **DTL / DT / S5TIME / TIME / TOD / DATE-as-DateTime** route through
|
||||
`S7DateTimeCodec` for the same reason.
|
||||
- **Arrays** (`ElementCount > 1`) carry a per-tag width of `N × elementBytes`
|
||||
and would silently mis-decode if the slice landed mid-block.
|
||||
|
||||
Each opaque-size tag emits its own standalone `Plc.ReadBytesAsync` call.
|
||||
A STRING in the middle of a contiguous run of DBWs will split the
|
||||
neighbour reads into "before STRING" and "after STRING" merged ranges
|
||||
without straddling the STRING's bytes — verified by the
|
||||
`S7BlockCoalescingPlannerTests` unit suite.
|
||||
|
||||
#### Operator tuning: `BlockCoalescingGapBytes`
|
||||
|
||||
Surface knob in the driver options:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"Host": "10.0.0.50",
|
||||
"Port": 102,
|
||||
"CpuType": "S71500",
|
||||
"BlockCoalescingGapBytes": 16, // default
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Tuning guidance:
|
||||
|
||||
- **Raise the threshold (32-64 bytes)** when the PLC has chatty firmware
|
||||
(S7-1200 with default 240-byte PDU and many DBs scattered every few
|
||||
bytes). One fewer PDU round-trip beats over-fetching a kilobyte.
|
||||
- **Lower the threshold (4-8 bytes)** when DBs are sparsely populated
|
||||
with hot tags far apart — over-fetching dead bytes wastes the PDU
|
||||
envelope and the saved round-trip never materialises.
|
||||
- **Set to 0** to disable gap merging entirely (only literally adjacent
|
||||
ranges with `gap == 0` coalesce). Useful as a debugging knob: if a
|
||||
driver is misreading values you can flip the threshold to 0 to confirm
|
||||
the slice math isn't the culprit.
|
||||
- **Per-DB tuning isn't supported yet** — the knob is global per driver
|
||||
instance. If a site needs different policies for two DBs they live in
|
||||
different drivers (different `Host:Port` rows in the config DB).
|
||||
|
||||
#### Diagnostics counters
|
||||
|
||||
The driver surfaces three coalescing counters via `DriverHealth.Diagnostics`
|
||||
under the standard `<DriverType>.<Counter>` naming convention:
|
||||
|
||||
- `S7.TotalBlockReads` — number of `Plc.ReadBytesAsync` calls issued by
|
||||
the coalesced path. A fully-coalesced contiguous workload bumps this
|
||||
by 1 per `ReadAsync`.
|
||||
- `S7.TotalMultiVarBatches` — `Plc.ReadMultipleVarsAsync` batches issued
|
||||
for residual singletons that didn't merge. With perfect coalescing this
|
||||
stays at 0.
|
||||
- `S7.TotalSingleReads` — per-tag fallbacks (strings, dates, arrays,
|
||||
64-bit ints, anything that bypasses both the coalescer and the packer).
|
||||
|
||||
Observe via the `driver-diagnostics` RPC (`/api/v2/drivers/{id}/diagnostics`)
|
||||
or the Admin UI's per-driver dashboard.
|
||||
|
||||
## References
|
||||
|
||||
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
|
||||
|
||||
@@ -45,6 +45,13 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// Set when <paramref name="Source"/> is <see cref="NodeSourceKind.ScriptedAlarm"/> —
|
||||
/// stable logical id the ScriptedAlarmEngine addresses by. Null otherwise.
|
||||
/// </param>
|
||||
/// <param name="Description">
|
||||
/// Human-readable description for this attribute. When non-null + non-empty the generic
|
||||
/// node-manager surfaces the value as the OPC UA <c>Description</c> attribute on the
|
||||
/// Variable node so SCADA / engineering clients see the field comment from the source
|
||||
/// project (Studio 5000 tag descriptions, Galaxy attribute help text, etc.). Defaults to
|
||||
/// null so drivers that don't carry descriptions are unaffected.
|
||||
/// </param>
|
||||
public sealed record DriverAttributeInfo(
|
||||
string FullName,
|
||||
DriverDataType DriverDataType,
|
||||
@@ -56,7 +63,8 @@ public sealed record DriverAttributeInfo(
|
||||
bool WriteIdempotent = false,
|
||||
NodeSourceKind Source = NodeSourceKind.Driver,
|
||||
string? VirtualTagId = null,
|
||||
string? ScriptedAlarmId = null);
|
||||
string? ScriptedAlarmId = null,
|
||||
string? Description = null);
|
||||
|
||||
/// <summary>
|
||||
/// Per ADR-002 — discriminates which runtime subsystem owns this node's Read/Write/
|
||||
|
||||
@@ -25,7 +25,7 @@ public enum DriverCapability
|
||||
/// <summary><see cref="ITagDiscovery.DiscoverAsync"/>. Retries by default.</summary>
|
||||
Discover,
|
||||
|
||||
/// <summary><see cref="ISubscribable.SubscribeAsync"/> and unsubscribe. Retries by default.</summary>
|
||||
/// <summary><see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/> and unsubscribe. Retries by default.</summary>
|
||||
Subscribe,
|
||||
|
||||
/// <summary><see cref="IHostConnectivityProbe"/> probe loop. Retries by default.</summary>
|
||||
|
||||
@@ -25,4 +25,11 @@ public enum DriverDataType
|
||||
|
||||
/// <summary>Galaxy-style attribute reference encoded as an OPC UA String.</summary>
|
||||
Reference,
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>Duration</c> — a Double-encoded period in milliseconds. Subtype of Double
|
||||
/// in the address space; surfaced as <see cref="System.TimeSpan"/> in the driver layer.
|
||||
/// Used by IEC 61131-3 <c>TIME</c> / <c>TOD</c> attributes (TwinCAT et al.).
|
||||
/// </summary>
|
||||
Duration,
|
||||
}
|
||||
|
||||
@@ -7,10 +7,26 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
/// <param name="State">Current driver-instance state.</param>
|
||||
/// <param name="LastSuccessfulRead">Timestamp of the most recent successful equipment read; null if never.</param>
|
||||
/// <param name="LastError">Most recent error message; null when state is Healthy.</param>
|
||||
/// <param name="Diagnostics">
|
||||
/// Optional driver-attributable counters/metrics surfaced for the <c>driver-diagnostics</c>
|
||||
/// RPC (introduced for Modbus task #154). Drivers populate the dictionary with stable,
|
||||
/// well-known keys (e.g. <c>PublishRequestCount</c>, <c>NotificationsPerSecond</c>);
|
||||
/// Core treats it as opaque metadata. Defaulted to an empty read-only dictionary so
|
||||
/// existing drivers and call-sites that don't construct this field stay back-compat.
|
||||
/// </param>
|
||||
public sealed record DriverHealth(
|
||||
DriverState State,
|
||||
DateTime? LastSuccessfulRead,
|
||||
string? LastError);
|
||||
string? LastError,
|
||||
IReadOnlyDictionary<string, double>? Diagnostics = null)
|
||||
{
|
||||
/// <summary>Driver-attributable counters, empty when the driver doesn't surface any.</summary>
|
||||
public IReadOnlyDictionary<string, double> DiagnosticsOrEmpty
|
||||
=> Diagnostics ?? EmptyDiagnostics;
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, double> EmptyDiagnostics
|
||||
= new Dictionary<string, double>(0);
|
||||
}
|
||||
|
||||
/// <summary>Driver-instance lifecycle state.</summary>
|
||||
public enum DriverState
|
||||
|
||||
@@ -35,8 +35,159 @@ public interface IAddressSpaceBuilder
|
||||
/// <c>_base</c> equipment-class template).
|
||||
/// </summary>
|
||||
void AddProperty(string browseName, DriverDataType dataType, object? value);
|
||||
|
||||
/// <summary>
|
||||
/// Register a type-definition node (ObjectType / VariableType / DataType / ReferenceType)
|
||||
/// mirrored from an upstream OPC UA server. Optional surface — drivers that don't mirror
|
||||
/// types simply never call it; address-space builders that don't materialise upstream
|
||||
/// types can leave the default no-op in place. Default implementation drops the call so
|
||||
/// adding this method doesn't break existing <see cref="IAddressSpaceBuilder"/>
|
||||
/// implementations.
|
||||
/// </summary>
|
||||
/// <param name="info">Metadata describing the type-definition node to mirror.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The OPC UA Client driver is the primary caller — it walks <c>i=86</c>
|
||||
/// (TypesFolder) during <c>DiscoverAsync</c> when
|
||||
/// <c>OpcUaClientDriverOptions.MirrorTypeDefinitions</c> is set so downstream clients
|
||||
/// see the upstream type system instead of rendering structured-type values as opaque
|
||||
/// strings.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The default no-op is intentional — most builders (Galaxy, Modbus, FOCAS, S7,
|
||||
/// TwinCAT, AB-CIP) don't have a meaningful type folder to project into and would
|
||||
/// otherwise need empty-stub overrides.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
void RegisterTypeNode(MirroredTypeNodeInfo info) { /* default: no-op */ }
|
||||
|
||||
/// <summary>
|
||||
/// Register a method node mirrored from an upstream OPC UA server. The method is
|
||||
/// registered as a child of the current builder scope (i.e. the folder representing
|
||||
/// the upstream Object that owns the method). Optional surface — drivers that don't
|
||||
/// mirror methods simply never call it; address-space builders that don't materialise
|
||||
/// method nodes can leave the default no-op in place. Default implementation drops
|
||||
/// the call so adding this method doesn't break existing
|
||||
/// <see cref="IAddressSpaceBuilder"/> implementations.
|
||||
/// </summary>
|
||||
/// <param name="info">Metadata describing the method node, including input/output argument schemas.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The OPC UA Client driver is the primary caller — it picks up
|
||||
/// <c>NodeClass.Method</c> nodes during the <c>HierarchicalReferences</c> browse
|
||||
/// pass, then walks each method's <c>HasProperty</c> references to harvest the
|
||||
/// <c>InputArguments</c> / <c>OutputArguments</c> property values.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The OPC UA server-side <c>DriverNodeManager</c> overrides this to materialize
|
||||
/// a real <c>MethodNode</c> in the local address space and wire its
|
||||
/// <c>OnCallMethod</c> handler to the driver's
|
||||
/// <see cref="IMethodInvoker.CallMethodAsync"/>. Other builders (Galaxy, Modbus,
|
||||
/// FOCAS, S7, TwinCAT, AB-CIP, AB-Legacy) ignore the projection because their
|
||||
/// backends don't expose method nodes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
void RegisterMethodNode(MirroredMethodNodeInfo info) { /* default: no-op */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata describing a single method node mirrored from an upstream OPC UA server.
|
||||
/// Built by the OPC UA Client driver during the discovery browse pass and consumed by
|
||||
/// <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>.
|
||||
/// </summary>
|
||||
/// <param name="BrowseName">OPC UA BrowseName segment from the upstream BrowseName.</param>
|
||||
/// <param name="DisplayName">Human-readable display name; falls back to <paramref name="BrowseName"/>.</param>
|
||||
/// <param name="ObjectNodeId">
|
||||
/// Stringified NodeId of the parent Object that owns this method — the <c>ObjectId</c>
|
||||
/// argument the dispatcher passes back to <see cref="IMethodInvoker.CallMethodAsync"/>.
|
||||
/// </param>
|
||||
/// <param name="MethodNodeId">
|
||||
/// Stringified NodeId of the method node itself — the <c>MethodId</c> argument.
|
||||
/// </param>
|
||||
/// <param name="InputArguments">
|
||||
/// Declaration of the method's input arguments, in order. <c>null</c> or empty when the
|
||||
/// method takes no inputs (or the upstream property couldn't be read).
|
||||
/// </param>
|
||||
/// <param name="OutputArguments">
|
||||
/// Declaration of the method's output arguments, in order. <c>null</c> or empty when the
|
||||
/// method returns no outputs (or the upstream property couldn't be read).
|
||||
/// </param>
|
||||
public sealed record MirroredMethodNodeInfo(
|
||||
string BrowseName,
|
||||
string DisplayName,
|
||||
string ObjectNodeId,
|
||||
string MethodNodeId,
|
||||
IReadOnlyList<MethodArgumentInfo>? InputArguments,
|
||||
IReadOnlyList<MethodArgumentInfo>? OutputArguments);
|
||||
|
||||
/// <summary>
|
||||
/// One row of an OPC UA Argument array — name + data type + array hint. Mirrors the
|
||||
/// <c>Opc.Ua.Argument</c> structure but without the SDK-only types so this DTO can live
|
||||
/// in <c>Core.Abstractions</c>.
|
||||
/// </summary>
|
||||
/// <param name="Name">Argument name from the upstream Argument structure.</param>
|
||||
/// <param name="DriverDataType">
|
||||
/// Mapped local <see cref="DriverDataType"/>. Unknown / structured upstream types fall
|
||||
/// through to <see cref="DriverDataType.String"/> — same convention as variable mirroring.
|
||||
/// </param>
|
||||
/// <param name="ValueRank">
|
||||
/// OPC UA ValueRank: <c>-1</c> = scalar, <c>0</c> = OneOrMoreDimensions, <c>1+</c> = array
|
||||
/// dimensions. Driven directly from the upstream Argument's ValueRank.
|
||||
/// </param>
|
||||
/// <param name="Description">
|
||||
/// Human-readable description from the upstream Argument structure; <c>null</c> when the
|
||||
/// upstream doesn't carry one.
|
||||
/// </param>
|
||||
public sealed record MethodArgumentInfo(
|
||||
string Name,
|
||||
DriverDataType DriverDataType,
|
||||
int ValueRank,
|
||||
string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// Categorises a mirrored type-definition node so the receiving builder can route it into
|
||||
/// the right OPC UA standard subtree (<c>ObjectTypesFolder</c>, <c>VariableTypesFolder</c>,
|
||||
/// <c>DataTypesFolder</c>, <c>ReferenceTypesFolder</c>) when projecting upstream types into
|
||||
/// the local address space.
|
||||
/// </summary>
|
||||
public enum MirroredTypeKind
|
||||
{
|
||||
ObjectType,
|
||||
VariableType,
|
||||
DataType,
|
||||
ReferenceType,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata describing a single type-definition node mirrored from an upstream OPC UA
|
||||
/// server. Built by the OPC UA Client driver during type-mirror pass and consumed by
|
||||
/// <see cref="IAddressSpaceBuilder.RegisterTypeNode"/>.
|
||||
/// </summary>
|
||||
/// <param name="Kind">Type category — drives which standard sub-folder the node lives under.</param>
|
||||
/// <param name="UpstreamNodeId">
|
||||
/// Stringified upstream NodeId (e.g. <c>"ns=2;i=1234"</c>) — preserves the original identity
|
||||
/// so a builder that wants to project the type with a stable cross-namespace reference can do
|
||||
/// so. The driver applies any configured namespace remap before stamping this field.
|
||||
/// </param>
|
||||
/// <param name="BrowseName">OPC UA BrowseName segment from the upstream BrowseName.</param>
|
||||
/// <param name="DisplayName">Human-readable display name; falls back to <paramref name="BrowseName"/>.</param>
|
||||
/// <param name="SuperTypeNodeId">
|
||||
/// Stringified upstream NodeId of the super-type (parent type), or <c>null</c> when the node
|
||||
/// sits directly under the root (e.g. <c>BaseObjectType</c>, <c>BaseVariableType</c>). Lets
|
||||
/// the builder reconstruct the inheritance chain.
|
||||
/// </param>
|
||||
/// <param name="IsAbstract">
|
||||
/// <c>true</c> when the upstream node has the <c>IsAbstract</c> flag set (Object / Variable /
|
||||
/// ReferenceType). DataTypes also expose this — the driver passes it through verbatim.
|
||||
/// </param>
|
||||
public sealed record MirroredTypeNodeInfo(
|
||||
MirroredTypeKind Kind,
|
||||
string UpstreamNodeId,
|
||||
string BrowseName,
|
||||
string DisplayName,
|
||||
string? SuperTypeNodeId,
|
||||
bool IsAbstract);
|
||||
|
||||
/// <summary>Opaque handle for a registered variable. Used by Core for subscription routing.</summary>
|
||||
public interface IVariableHandle
|
||||
{
|
||||
|
||||
27
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs
Normal file
27
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDriverControl.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Optional control-plane capability — drivers whose backend exposes a way to refresh
|
||||
/// the symbol table on-demand (without tearing the driver down) implement this so the
|
||||
/// Admin UI / CLI can trigger a re-walk in response to an operator action.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Distinct from <see cref="IRediscoverable"/>: that interface is the driver telling Core
|
||||
/// a refresh is needed; this one is Core asking the driver to refresh now. For drivers that
|
||||
/// implement both, the typical wiring is "operator clicks Rebrowse → Core calls
|
||||
/// <see cref="RebrowseAsync"/> → driver re-walks → driver fires
|
||||
/// <c>OnRediscoveryNeeded</c> so the address space is rebuilt".
|
||||
///
|
||||
/// For AB CIP this is the "force re-walk of @tags" hook — useful after a controller
|
||||
/// program download added new tags but the static config still drives the address space.
|
||||
/// </remarks>
|
||||
public interface IDriverControl
|
||||
{
|
||||
/// <summary>
|
||||
/// Re-run the driver's discovery pass against live backend state and stream the
|
||||
/// resulting nodes through the supplied builder. Implementations must be safe to call
|
||||
/// concurrently with reads / writes; they typically serialize internally so a second
|
||||
/// concurrent rebrowse waits for the first to complete rather than racing it.
|
||||
/// </summary>
|
||||
Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken);
|
||||
}
|
||||
82
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs
Normal file
82
src/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IMethodInvoker.cs
Normal file
@@ -0,0 +1,82 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Driver capability for invoking OPC UA Methods on the upstream backend (the OPC UA
|
||||
/// <c>Call</c> service). Optional — only drivers whose backends carry method nodes
|
||||
/// implement it. Currently the OPC UA Client driver is the only implementer; tag-based
|
||||
/// drivers (Modbus, S7, FOCAS, Galaxy, AB-CIP, AB-Legacy, TwinCAT) don't expose method
|
||||
/// nodes so they don't need this surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Per <c>docs/v2/plan.md</c> decision #4 (composable capability interfaces) — the
|
||||
/// server-side <c>DriverNodeManager</c> discovers method-bearing drivers via an
|
||||
/// <c>is IMethodInvoker</c> check and routes <c>OnCallMethod</c> handlers to
|
||||
/// <see cref="CallMethodAsync"/>. Drivers that don't implement the interface simply
|
||||
/// never have method nodes registered for them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The address-space mirror is driven by <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>
|
||||
/// — drivers register the method node + its <c>InputArguments</c> /
|
||||
/// <c>OutputArguments</c> properties during discovery, then invocations land back on
|
||||
/// <see cref="CallMethodAsync"/> via the server-side dispatcher.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IMethodInvoker
|
||||
{
|
||||
/// <summary>
|
||||
/// Invoke an upstream OPC UA Method. The driver translates input arguments into the
|
||||
/// wire-level <c>CallMethodRequest</c>, dispatches via the active session, and packs
|
||||
/// the response back into a <see cref="MethodCallResult"/>. Per-argument validation
|
||||
/// errors flow through <see cref="MethodCallResult.InputArgumentResults"/>; method-level
|
||||
/// errors (<c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, etc.) flow through
|
||||
/// <see cref="MethodCallResult.StatusCode"/>.
|
||||
/// </summary>
|
||||
/// <param name="objectNodeId">
|
||||
/// Stringified NodeId of the OPC UA Object that owns the method (the <c>ObjectId</c>
|
||||
/// field of <c>CallMethodRequest</c>). Same serialization as <c>IReadable</c>'s
|
||||
/// <c>fullReference</c> — <c>ns=2;s=…</c> / <c>i=…</c> / <c>nsu=…;…</c>.
|
||||
/// </param>
|
||||
/// <param name="methodNodeId">
|
||||
/// Stringified NodeId of the Method node itself (the <c>MethodId</c> field).
|
||||
/// </param>
|
||||
/// <param name="inputs">
|
||||
/// Input arguments in declaration order. The driver wraps each value as a
|
||||
/// <c>Variant</c>; callers pass CLR primitives (plus arrays) — the wire-level
|
||||
/// encoding is the driver's concern.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Per-call cancellation.</param>
|
||||
/// <returns>
|
||||
/// Result of the call — see <see cref="MethodCallResult"/>. Never throws for a
|
||||
/// <c>Bad</c> upstream status; the bad code is surfaced via the result so the caller
|
||||
/// can map it onto an OPC UA service-result for downstream clients.
|
||||
/// </returns>
|
||||
Task<MethodCallResult> CallMethodAsync(
|
||||
string objectNodeId,
|
||||
string methodNodeId,
|
||||
object[] inputs,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a single OPC UA <c>Call</c> service invocation.
|
||||
/// </summary>
|
||||
/// <param name="StatusCode">
|
||||
/// Method-level status. <c>0</c> = Good. Bad codes pass through verbatim from the
|
||||
/// upstream so downstream clients see the canonical OPC UA error (e.g.
|
||||
/// <c>BadMethodInvalid</c>, <c>BadUserAccessDenied</c>, <c>BadArgumentsMissing</c>).
|
||||
/// </param>
|
||||
/// <param name="Outputs">
|
||||
/// Output argument values in declaration order. <c>null</c> when the upstream returned
|
||||
/// no output arguments (or returned a Bad status before producing any).
|
||||
/// </param>
|
||||
/// <param name="InputArgumentResults">
|
||||
/// Per-input-argument status codes. <c>null</c> when the upstream didn't surface
|
||||
/// per-argument validation results (typical for Good calls). Each entry is the OPC UA
|
||||
/// status code for the matching input argument — drivers can use this to surface
|
||||
/// <c>BadTypeMismatch</c>, <c>BadOutOfRange</c>, etc. on a specific argument.
|
||||
/// </param>
|
||||
public sealed record MethodCallResult(
|
||||
uint StatusCode,
|
||||
object[]? Outputs,
|
||||
uint[]? InputArgumentResults);
|
||||
@@ -20,7 +20,29 @@ public interface ISubscribable
|
||||
TimeSpan publishingInterval,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Cancel a subscription returned by <see cref="SubscribeAsync"/>.</summary>
|
||||
/// <summary>
|
||||
/// Subscribe to data changes with per-tag advanced tuning (sampling interval, queue
|
||||
/// size, monitoring mode, deadband filter). Drivers that don't have a native concept
|
||||
/// of these knobs (e.g. polled drivers like Modbus) MAY ignore the per-tag knobs and
|
||||
/// delegate to the simple
|
||||
/// <see cref="SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>
|
||||
/// overload — the default implementation does exactly that, so existing implementers
|
||||
/// compile unchanged.
|
||||
/// </summary>
|
||||
/// <param name="tags">Per-tag subscription specs. <see cref="MonitoredTagSpec.TagName"/> is the driver-side full reference.</param>
|
||||
/// <param name="publishingInterval">Subscription publishing interval, applied to the whole batch.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <returns>Opaque subscription handle for <see cref="UnsubscribeAsync"/>.</returns>
|
||||
Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<MonitoredTagSpec> tags,
|
||||
TimeSpan publishingInterval,
|
||||
CancellationToken cancellationToken)
|
||||
=> SubscribeAsync(
|
||||
tags.Select(t => t.TagName).ToList(),
|
||||
publishingInterval,
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>Cancel a subscription returned by either <c>SubscribeAsync</c> overload.</summary>
|
||||
Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
@@ -30,7 +52,7 @@ public interface ISubscribable
|
||||
event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
}
|
||||
|
||||
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync"/>.</summary>
|
||||
/// <summary>Opaque subscription identity returned by <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>.</summary>
|
||||
public interface ISubscriptionHandle
|
||||
{
|
||||
/// <summary>Driver-internal subscription identifier (for diagnostics + post-mortem).</summary>
|
||||
@@ -38,10 +60,99 @@ public interface ISubscriptionHandle
|
||||
}
|
||||
|
||||
/// <summary>Event payload for <see cref="ISubscribable.OnDataChange"/>.</summary>
|
||||
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync"/> call.</param>
|
||||
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/> call.</param>
|
||||
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
|
||||
/// <param name="Snapshot">New value + quality + timestamps.</param>
|
||||
public sealed record DataChangeEventArgs(
|
||||
ISubscriptionHandle SubscriptionHandle,
|
||||
string FullReference,
|
||||
DataValueSnapshot Snapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Per-tag subscription tuning. Maps onto OPC UA <c>MonitoredItem</c> properties for the
|
||||
/// OpcUaClient driver; non-OPC-UA drivers either map a subset (e.g. ADS picks up
|
||||
/// <see cref="SamplingIntervalMs"/>) or ignore the knobs entirely and fall back to the
|
||||
/// simple <see cref="ISubscribable.SubscribeAsync(IReadOnlyList{string}, TimeSpan, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
/// <param name="TagName">Driver-side full reference (e.g. <c>ns=2;s=Foo</c> for OPC UA).</param>
|
||||
/// <param name="SamplingIntervalMs">
|
||||
/// Server-side sampling rate in milliseconds. <c>null</c> = use the publishing interval.
|
||||
/// Sub-publish-interval values let a server sample faster than it publishes (queue +
|
||||
/// coalesce), useful for events that change between publish ticks.
|
||||
/// </param>
|
||||
/// <param name="QueueSize">Server-side notification queue depth. <c>null</c> = driver default (1).</param>
|
||||
/// <param name="DiscardOldest">
|
||||
/// When the server-side queue overflows: <c>true</c> drops oldest, <c>false</c> drops newest.
|
||||
/// <c>null</c> = driver default (true — preserve recency).
|
||||
/// </param>
|
||||
/// <param name="MonitoringMode">
|
||||
/// Per-item monitoring mode. <c>Reporting</c> = sample + publish, <c>Sampling</c> = sample
|
||||
/// but suppress publishing (useful with triggering), <c>Disabled</c> = neither.
|
||||
/// </param>
|
||||
/// <param name="DataChangeFilter">
|
||||
/// Optional data-change filter (deadband + trigger semantics). <c>null</c> = no filter
|
||||
/// (every change publishes regardless of magnitude).
|
||||
/// </param>
|
||||
public sealed record MonitoredTagSpec(
|
||||
string TagName,
|
||||
double? SamplingIntervalMs = null,
|
||||
uint? QueueSize = null,
|
||||
bool? DiscardOldest = null,
|
||||
SubscriptionMonitoringMode? MonitoringMode = null,
|
||||
DataChangeFilterSpec? DataChangeFilter = null);
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>DataChangeFilter</c> spec. Mirrors the OPC UA Part 4 §7.17.2 structure but
|
||||
/// lives in Core.Abstractions so non-OpcUaClient drivers (e.g. Modbus, S7) can accept it
|
||||
/// as metadata even if they ignore the deadband mechanics.
|
||||
/// </summary>
|
||||
/// <param name="Trigger">When to fire: status only / status+value / status+value+timestamp.</param>
|
||||
/// <param name="DeadbandType">Deadband mode: none / absolute (engineering units) / percent of EURange.</param>
|
||||
/// <param name="DeadbandValue">
|
||||
/// Magnitude of the deadband. For <see cref="OtOpcUa.Core.Abstractions.DeadbandType.Absolute"/>
|
||||
/// this is in the variable's engineering units; for <see cref="OtOpcUa.Core.Abstractions.DeadbandType.Percent"/>
|
||||
/// it's a 0..100 percentage of EURange (server returns BadFilterNotAllowed if EURange isn't set).
|
||||
/// </param>
|
||||
public sealed record DataChangeFilterSpec(
|
||||
DataChangeTrigger Trigger,
|
||||
DeadbandType DeadbandType,
|
||||
double DeadbandValue);
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA <c>DataChangeTrigger</c> values. Wraps the SDK enum so Core.Abstractions doesn't
|
||||
/// leak an OPC-UA-stack reference into every driver project.
|
||||
/// </summary>
|
||||
public enum DataChangeTrigger
|
||||
{
|
||||
/// <summary>Fire only when StatusCode changes.</summary>
|
||||
Status = 0,
|
||||
/// <summary>Fire when StatusCode or Value changes (the OPC UA default).</summary>
|
||||
StatusValue = 1,
|
||||
/// <summary>Fire when StatusCode, Value, or SourceTimestamp changes.</summary>
|
||||
StatusValueTimestamp = 2,
|
||||
}
|
||||
|
||||
/// <summary>OPC UA deadband-filter modes.</summary>
|
||||
public enum DeadbandType
|
||||
{
|
||||
/// <summary>No deadband — every value change publishes.</summary>
|
||||
None = 0,
|
||||
/// <summary>Deadband expressed in the variable's engineering units.</summary>
|
||||
Absolute = 1,
|
||||
/// <summary>Deadband expressed as 0..100 percent of the variable's EURange.</summary>
|
||||
Percent = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-item subscription monitoring mode. Wraps the OPC UA SDK's <c>MonitoringMode</c>
|
||||
/// so Core.Abstractions stays SDK-free.
|
||||
/// </summary>
|
||||
public enum SubscriptionMonitoringMode
|
||||
{
|
||||
/// <summary>Item is created but neither sampling nor publishing.</summary>
|
||||
Disabled = 0,
|
||||
/// <summary>Item samples and queues but does not publish (useful with triggering).</summary>
|
||||
Sampling = 1,
|
||||
/// <summary>Item samples and publishes — the OPC UA default.</summary>
|
||||
Reporting = 2,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Cli.Common;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Force a controller-side @tags re-walk on a live AbCip driver instance. Issue #233 —
|
||||
/// online tag-DB refresh trigger. The CLI variant builds a transient driver against the
|
||||
/// supplied gateway, runs <see cref="AbCipDriver.RebrowseAsync"/>, and prints the freshly
|
||||
/// discovered tag names. In-server (Tier-A) operators wire this same call to an Admin UI
|
||||
/// button so a controller program-download is reflected in the address space without a
|
||||
/// driver restart.
|
||||
/// </summary>
|
||||
[Command("rebrowse", Description =
|
||||
"Re-walk the AB CIP controller symbol table (force @tags refresh) and print discovered tags.")]
|
||||
public sealed class RebrowseCommand : AbCipCommandBase
|
||||
{
|
||||
public override async ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
ConfigureLogging();
|
||||
var ct = console.RegisterCancellationHandler();
|
||||
|
||||
// EnableControllerBrowse must be true for the @tags walk to happen; the CLI baseline
|
||||
// (BuildOptions in AbCipCommandBase) leaves it off for one-shot probes, so we flip it
|
||||
// here without touching the base helper.
|
||||
var baseOpts = BuildOptions(tags: []);
|
||||
var options = new AbCipDriverOptions
|
||||
{
|
||||
Devices = baseOpts.Devices,
|
||||
Tags = baseOpts.Tags,
|
||||
Timeout = baseOpts.Timeout,
|
||||
Probe = baseOpts.Probe,
|
||||
EnableControllerBrowse = true,
|
||||
EnableAlarmProjection = false,
|
||||
};
|
||||
|
||||
await using var driver = new AbCipDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
|
||||
var builder = new ConsoleAddressSpaceBuilder();
|
||||
await driver.RebrowseAsync(builder, ct);
|
||||
|
||||
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||
await console.Output.WriteLineAsync($"Family: {Family}");
|
||||
await console.Output.WriteLineAsync($"Variables: {builder.VariableCount}");
|
||||
await console.Output.WriteLineAsync();
|
||||
foreach (var line in builder.Lines)
|
||||
await console.Output.WriteLineAsync(line);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await driver.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-memory <see cref="IAddressSpaceBuilder"/> that flattens the tree to one
|
||||
/// line per variable for CLI display. Folder nesting is captured in the prefix so the
|
||||
/// operator can see the same shape the in-server builder would receive.
|
||||
/// </summary>
|
||||
private sealed class ConsoleAddressSpaceBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
private readonly string _prefix;
|
||||
private readonly Counter _counter;
|
||||
public List<string> Lines { get; }
|
||||
public int VariableCount => _counter.Count;
|
||||
|
||||
public ConsoleAddressSpaceBuilder() : this("", new List<string>(), new Counter()) { }
|
||||
private ConsoleAddressSpaceBuilder(string prefix, List<string> sharedLines, Counter counter)
|
||||
{
|
||||
_prefix = prefix;
|
||||
Lines = sharedLines;
|
||||
_counter = counter;
|
||||
}
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{
|
||||
var newPrefix = string.IsNullOrEmpty(_prefix) ? browseName : $"{_prefix}/{browseName}";
|
||||
return new ConsoleAddressSpaceBuilder(newPrefix, Lines, _counter);
|
||||
}
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{
|
||||
_counter.Count++;
|
||||
Lines.Add($" {_prefix}/{browseName} ({info.DriverDataType}, {info.SecurityClass})");
|
||||
return new Handle(info.FullName);
|
||||
}
|
||||
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class Counter { public int Count; }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using CliFx;
|
||||
using CliFx.Attributes;
|
||||
using CliFx.Exceptions;
|
||||
using CliFx.Infrastructure;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Cli.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Dump the merged tag table from an <see cref="AbCipDriverOptions"/> JSON config to a
|
||||
/// Kepware-format CSV. The command reads the pre-declared <c>Tags</c> list, pulls in any
|
||||
/// <c>L5kImports</c> / <c>L5xImports</c> / <c>CsvImports</c> entries, applies the same
|
||||
/// declared-wins precedence used by the live driver, and writes the union as one CSV.
|
||||
/// Mirrors the round-trip path operators want for Excel-driven editing: export → edit →
|
||||
/// re-import via the driver's <c>CsvImports</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The command does not contact any PLC — it is a pure transform over the options JSON.
|
||||
/// <c>--driver-options-json</c> may point at a full options file or at a fragment that
|
||||
/// deserialises to <see cref="AbCipDriverOptions"/>.
|
||||
/// </remarks>
|
||||
[Command("tag-export", Description = "Export the merged tag table from a driver-options JSON to Kepware CSV.")]
|
||||
public sealed class TagExportCommand : ICommand
|
||||
{
|
||||
[CommandOption("driver-options-json", Description =
|
||||
"Path to a JSON file deserialising to AbCipDriverOptions (Tags + L5kImports + " +
|
||||
"L5xImports + CsvImports). Imports with FilePath are loaded relative to the JSON.",
|
||||
IsRequired = true)]
|
||||
public string DriverOptionsJsonPath { get; init; } = default!;
|
||||
|
||||
[CommandOption("out", 'o', Description = "Output CSV path (UTF-8, no BOM).", IsRequired = true)]
|
||||
public string OutputPath { get; init; } = default!;
|
||||
|
||||
public ValueTask ExecuteAsync(IConsole console)
|
||||
{
|
||||
if (!File.Exists(DriverOptionsJsonPath))
|
||||
throw new CommandException($"driver-options-json '{DriverOptionsJsonPath}' does not exist.");
|
||||
|
||||
var json = File.ReadAllText(DriverOptionsJsonPath);
|
||||
var opts = JsonSerializer.Deserialize<AbCipDriverOptions>(json, JsonOpts)
|
||||
?? throw new CommandException("driver-options-json deserialised to null.");
|
||||
|
||||
var basePath = Path.GetDirectoryName(Path.GetFullPath(DriverOptionsJsonPath)) ?? string.Empty;
|
||||
|
||||
var declaredNames = new HashSet<string>(
|
||||
opts.Tags.Select(t => t.Name), StringComparer.OrdinalIgnoreCase);
|
||||
var allTags = new List<AbCipTagDefinition>(opts.Tags);
|
||||
|
||||
foreach (var import in opts.L5kImports)
|
||||
MergeL5(import.DeviceHostAddress, ResolvePath(import.FilePath, basePath),
|
||||
import.InlineText, import.NamePrefix, L5kParser.Parse, declaredNames, allTags);
|
||||
foreach (var import in opts.L5xImports)
|
||||
MergeL5(import.DeviceHostAddress, ResolvePath(import.FilePath, basePath),
|
||||
import.InlineText, import.NamePrefix, L5xParser.Parse, declaredNames, allTags);
|
||||
foreach (var import in opts.CsvImports)
|
||||
MergeCsv(import, basePath, declaredNames, allTags);
|
||||
|
||||
CsvTagExporter.WriteFile(allTags, OutputPath);
|
||||
console.Output.WriteLine($"Wrote {allTags.Count} tag(s) to {OutputPath}");
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static string? ResolvePath(string? path, string basePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return path;
|
||||
return Path.IsPathRooted(path) ? path : Path.Combine(basePath, path);
|
||||
}
|
||||
|
||||
private static void MergeL5(
|
||||
string deviceHost, string? filePath, string? inlineText, string namePrefix,
|
||||
Func<IL5kSource, L5kDocument> parse,
|
||||
HashSet<string> declaredNames, List<AbCipTagDefinition> allTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceHost)) return;
|
||||
IL5kSource? src = null;
|
||||
if (!string.IsNullOrEmpty(filePath)) src = new FileL5kSource(filePath);
|
||||
else if (!string.IsNullOrEmpty(inlineText)) src = new StringL5kSource(inlineText);
|
||||
if (src is null) return;
|
||||
|
||||
var doc = parse(src);
|
||||
var ingest = new L5kIngest { DefaultDeviceHostAddress = deviceHost, NamePrefix = namePrefix };
|
||||
foreach (var tag in ingest.Ingest(doc).Tags)
|
||||
{
|
||||
if (declaredNames.Contains(tag.Name)) continue;
|
||||
allTags.Add(tag);
|
||||
declaredNames.Add(tag.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private static void MergeCsv(
|
||||
AbCipCsvImportOptions import, string basePath,
|
||||
HashSet<string> declaredNames, List<AbCipTagDefinition> allTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress)) return;
|
||||
string? text = null;
|
||||
var resolved = ResolvePath(import.FilePath, basePath);
|
||||
if (!string.IsNullOrEmpty(resolved)) text = File.ReadAllText(resolved);
|
||||
else if (!string.IsNullOrEmpty(import.InlineText)) text = import.InlineText;
|
||||
if (text is null) return;
|
||||
|
||||
var importer = new CsvTagImporter
|
||||
{
|
||||
DefaultDeviceHostAddress = import.DeviceHostAddress,
|
||||
NamePrefix = import.NamePrefix,
|
||||
};
|
||||
foreach (var tag in importer.Import(text).Tags)
|
||||
{
|
||||
if (declaredNames.Contains(tag.Name)) continue;
|
||||
allTags.Add(tag);
|
||||
declaredNames.Add(tag.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
Converters = { new JsonStringEnumConverter() },
|
||||
};
|
||||
}
|
||||
94
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayReadPlanner.cs
Normal file
94
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayReadPlanner.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-1.3 — issues one libplctag tag-create with <c>ElementCount=N</c> per Rockwell
|
||||
/// array-slice tag (<c>Tag[0..N]</c> in <see cref="AbCipTagPath"/>), then decodes the
|
||||
/// contiguous buffer at element stride into <c>N</c> typed values. Mirrors the whole-UDT
|
||||
/// planner pattern (<see cref="AbCipUdtReadPlanner"/>): pure shape — the planner never
|
||||
/// touches the runtime + never reads the PLC, the driver wires the runtime in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Stride is the natural Logix size of the element type (DInt = 4, Real = 4, LInt = 8).
|
||||
/// Bool / String / Structure slices aren't supported here — Logix packs BOOLs into a host
|
||||
/// byte (no fixed stride), STRING members carry a Length+DATA pair that's not a flat array,
|
||||
/// and structure arrays need the CIP Template Object reader (PR-tracked separately).</para>
|
||||
///
|
||||
/// <para>Output is a single <c>object[]</c> snapshot value containing the N decoded
|
||||
/// elements at indices 0..Count-1. Pairing with one slice tag = one snapshot keeps the
|
||||
/// <c>ReadAsync</c> 1:1 contract (one fullReference -> one snapshot) intact.</para>
|
||||
/// </remarks>
|
||||
public static class AbCipArrayReadPlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// Build the libplctag create-params + decode descriptor for a slice tag. Returns
|
||||
/// <c>null</c> when the slice element type isn't supported under this declaration-only
|
||||
/// decoder (Bool / String / Structure / unrecognised) — the driver falls back to the
|
||||
/// scalar read path so the operator gets a clean per-element result instead.
|
||||
/// </summary>
|
||||
public static AbCipArrayReadPlan? TryBuild(
|
||||
AbCipTagDefinition definition,
|
||||
AbCipTagPath parsedPath,
|
||||
AbCipTagCreateParams baseParams)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(definition);
|
||||
ArgumentNullException.ThrowIfNull(parsedPath);
|
||||
ArgumentNullException.ThrowIfNull(baseParams);
|
||||
if (parsedPath.Slice is null) return null;
|
||||
|
||||
if (!TryGetStride(definition.DataType, out var stride)) return null;
|
||||
|
||||
var slice = parsedPath.Slice;
|
||||
var createParams = baseParams with
|
||||
{
|
||||
TagName = parsedPath.ToLibplctagSliceArrayName(),
|
||||
ElementCount = slice.Count,
|
||||
};
|
||||
|
||||
return new AbCipArrayReadPlan(definition.DataType, slice, stride, createParams);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode <paramref name="plan"/>.Count elements from <paramref name="runtime"/> at
|
||||
/// element stride. Caller has already invoked <see cref="IAbCipTagRuntime.ReadAsync"/>
|
||||
/// and confirmed <see cref="IAbCipTagRuntime.GetStatus"/> == 0.
|
||||
/// </summary>
|
||||
public static object?[] Decode(AbCipArrayReadPlan plan, IAbCipTagRuntime runtime)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plan);
|
||||
ArgumentNullException.ThrowIfNull(runtime);
|
||||
|
||||
var values = new object?[plan.Slice.Count];
|
||||
for (var i = 0; i < plan.Slice.Count; i++)
|
||||
values[i] = runtime.DecodeValueAt(plan.ElementType, i * plan.Stride, bitIndex: null);
|
||||
return values;
|
||||
}
|
||||
|
||||
private static bool TryGetStride(AbCipDataType type, out int stride)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbCipDataType.SInt: case AbCipDataType.USInt:
|
||||
stride = 1; return true;
|
||||
case AbCipDataType.Int: case AbCipDataType.UInt:
|
||||
stride = 2; return true;
|
||||
case AbCipDataType.DInt: case AbCipDataType.UDInt:
|
||||
case AbCipDataType.Real: case AbCipDataType.Dt:
|
||||
stride = 4; return true;
|
||||
case AbCipDataType.LInt: case AbCipDataType.ULInt:
|
||||
case AbCipDataType.LReal:
|
||||
stride = 8; return true;
|
||||
default:
|
||||
stride = 0; return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan output: the libplctag create-params for the single array-read tag plus the
|
||||
/// element-type / stride / slice metadata the decoder needs.
|
||||
/// </summary>
|
||||
public sealed record AbCipArrayReadPlan(
|
||||
AbCipDataType ElementType,
|
||||
AbCipTagPathSlice Slice,
|
||||
int Stride,
|
||||
AbCipTagCreateParams CreateParams);
|
||||
@@ -50,11 +50,12 @@ public static class AbCipDataTypeExtensions
|
||||
AbCipDataType.Bool => DriverDataType.Boolean,
|
||||
AbCipDataType.SInt or AbCipDataType.Int or AbCipDataType.DInt => DriverDataType.Int32,
|
||||
AbCipDataType.USInt or AbCipDataType.UInt or AbCipDataType.UDInt => DriverDataType.Int32,
|
||||
AbCipDataType.LInt or AbCipDataType.ULInt => DriverDataType.Int32, // TODO: Int64 — matches Modbus gap
|
||||
AbCipDataType.LInt => DriverDataType.Int64,
|
||||
AbCipDataType.ULInt => DriverDataType.UInt64,
|
||||
AbCipDataType.Real => DriverDataType.Float32,
|
||||
AbCipDataType.LReal => DriverDataType.Float64,
|
||||
AbCipDataType.String => DriverDataType.String,
|
||||
AbCipDataType.Dt => DriverDataType.Int32, // epoch-seconds DINT
|
||||
AbCipDataType.Dt => DriverDataType.Int64, // Logix v32+ DT == LINT epoch-millis
|
||||
AbCipDataType.Structure => DriverDataType.String, // placeholder until UDT PR 6 introduces a structured kind
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
@@ -21,7 +22,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// <see cref="PlcTagHandle"/> and reconnects each device.</para>
|
||||
/// </remarks>
|
||||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDisposable, IAsyncDisposable
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, IDriverControl, IDisposable, IAsyncDisposable
|
||||
{
|
||||
private readonly AbCipDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
@@ -33,6 +34,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, AbCipTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly AbCipAlarmProjection _alarmProjection;
|
||||
private readonly SemaphoreSlim _discoverySemaphore = new(1, 1);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
@@ -121,7 +123,42 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
|
||||
_devices[device.HostAddress] = new DeviceState(addr, device, profile);
|
||||
}
|
||||
foreach (var tag in _options.Tags)
|
||||
// Pre-declared tags first; L5K imports fill in only the names not already covered
|
||||
// (operators can override an imported entry by re-declaring it under Tags).
|
||||
var declaredNames = new HashSet<string>(
|
||||
_options.Tags.Select(t => t.Name),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
var allTags = new List<AbCipTagDefinition>(_options.Tags);
|
||||
foreach (var import in _options.L5kImports)
|
||||
{
|
||||
MergeImport(
|
||||
deviceHost: import.DeviceHostAddress,
|
||||
filePath: import.FilePath,
|
||||
inlineText: import.InlineText,
|
||||
namePrefix: import.NamePrefix,
|
||||
parse: L5kParser.Parse,
|
||||
formatLabel: "L5K",
|
||||
declaredNames: declaredNames,
|
||||
allTags: allTags);
|
||||
}
|
||||
foreach (var import in _options.L5xImports)
|
||||
{
|
||||
MergeImport(
|
||||
deviceHost: import.DeviceHostAddress,
|
||||
filePath: import.FilePath,
|
||||
inlineText: import.InlineText,
|
||||
namePrefix: import.NamePrefix,
|
||||
parse: L5xParser.Parse,
|
||||
formatLabel: "L5X",
|
||||
declaredNames: declaredNames,
|
||||
allTags: allTags);
|
||||
}
|
||||
foreach (var import in _options.CsvImports)
|
||||
{
|
||||
MergeCsvImport(import, declaredNames, allTags);
|
||||
}
|
||||
|
||||
foreach (var tag in allTags)
|
||||
{
|
||||
_tagsByName[tag.Name] = tag;
|
||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||
@@ -134,7 +171,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
TagPath: $"{tag.TagPath}.{member.Name}",
|
||||
DataType: member.DataType,
|
||||
Writable: member.Writable,
|
||||
WriteIdempotent: member.WriteIdempotent);
|
||||
WriteIdempotent: member.WriteIdempotent,
|
||||
StringLength: member.StringLength);
|
||||
_tagsByName[memberTag.Name] = memberTag;
|
||||
}
|
||||
}
|
||||
@@ -160,6 +198,84 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared L5K / L5X import path — keeps source-format selection (parser delegate) the
|
||||
/// only behavioural axis between the two formats. Adds the parser's tags to
|
||||
/// <paramref name="allTags"/> while skipping any name already covered by an earlier
|
||||
/// declaration or import (declared > L5K > L5X precedence falls out from call order).
|
||||
/// </summary>
|
||||
private static void MergeImport(
|
||||
string deviceHost,
|
||||
string? filePath,
|
||||
string? inlineText,
|
||||
string namePrefix,
|
||||
Func<IL5kSource, L5kDocument> parse,
|
||||
string formatLabel,
|
||||
HashSet<string> declaredNames,
|
||||
List<AbCipTagDefinition> allTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceHost))
|
||||
throw new InvalidOperationException(
|
||||
$"AbCip {formatLabel} import is missing DeviceHostAddress — every imported tag needs a target device.");
|
||||
IL5kSource? src = null;
|
||||
if (!string.IsNullOrEmpty(filePath))
|
||||
src = new FileL5kSource(filePath);
|
||||
else if (!string.IsNullOrEmpty(inlineText))
|
||||
src = new StringL5kSource(inlineText);
|
||||
if (src is null) return;
|
||||
|
||||
var doc = parse(src);
|
||||
var ingest = new L5kIngest
|
||||
{
|
||||
DefaultDeviceHostAddress = deviceHost,
|
||||
NamePrefix = namePrefix,
|
||||
};
|
||||
var result = ingest.Ingest(doc);
|
||||
foreach (var importedTag in result.Tags)
|
||||
{
|
||||
if (declaredNames.Contains(importedTag.Name)) continue;
|
||||
allTags.Add(importedTag);
|
||||
declaredNames.Add(importedTag.Name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CSV-import variant of <see cref="MergeImport"/>. The CSV path produces
|
||||
/// <see cref="AbCipTagDefinition"/> records directly (no intermediate document) so we
|
||||
/// can't share the L5K/L5X parser-delegate signature. Merge semantics are identical:
|
||||
/// a name already covered by a declaration or an earlier import is left untouched so
|
||||
/// the precedence chain (declared > L5K > L5X > CSV) holds.
|
||||
/// </summary>
|
||||
private static void MergeCsvImport(
|
||||
AbCipCsvImportOptions import,
|
||||
HashSet<string> declaredNames,
|
||||
List<AbCipTagDefinition> allTags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(import.DeviceHostAddress))
|
||||
throw new InvalidOperationException(
|
||||
"AbCip CSV import is missing DeviceHostAddress — every imported tag needs a target device.");
|
||||
|
||||
string? csvText = null;
|
||||
if (!string.IsNullOrEmpty(import.FilePath))
|
||||
csvText = System.IO.File.ReadAllText(import.FilePath);
|
||||
else if (!string.IsNullOrEmpty(import.InlineText))
|
||||
csvText = import.InlineText;
|
||||
if (csvText is null) return;
|
||||
|
||||
var importer = new CsvTagImporter
|
||||
{
|
||||
DefaultDeviceHostAddress = import.DeviceHostAddress,
|
||||
NamePrefix = import.NamePrefix,
|
||||
};
|
||||
var result = importer.Import(csvText);
|
||||
foreach (var tag in result.Tags)
|
||||
{
|
||||
if (declaredNames.Contains(tag.Name)) continue;
|
||||
allTags.Add(tag);
|
||||
declaredNames.Add(tag.Name);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -357,6 +473,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return;
|
||||
}
|
||||
|
||||
// PR abcip-1.3 — array-slice path. A tag whose TagPath ends in [N..M] dispatches to
|
||||
// AbCipArrayReadPlanner: one libplctag tag-create with ElementCount=N issues one
|
||||
// Rockwell array read; the contiguous buffer is decoded at element stride into a
|
||||
// single snapshot whose Value is an object[] of the N elements.
|
||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
if (parsedPath?.Slice is not null)
|
||||
{
|
||||
await ReadSliceAsync(fb, def, parsedPath, device, results, now, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||
@@ -372,8 +499,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return;
|
||||
}
|
||||
|
||||
var tagPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
var bitIndex = tagPath?.BitIndex;
|
||||
var bitIndex = parsedPath?.BitIndex;
|
||||
var value = runtime.DecodeValue(def.DataType, bitIndex);
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
@@ -390,6 +516,89 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-1.3 — slice read path. Builds an <see cref="AbCipArrayReadPlan"/> from the
|
||||
/// parsed slice path, materialises a per-tag runtime keyed by the tag's full name (so
|
||||
/// repeat reads reuse the same libplctag handle), issues one PLC array read, and
|
||||
/// decodes the contiguous buffer into <c>object?[]</c> at element stride. Unsupported
|
||||
/// element types fall back to <see cref="AbCipStatusMapper.BadNotSupported"/>.
|
||||
/// </summary>
|
||||
private async Task ReadSliceAsync(
|
||||
AbCipUdtReadFallback fb, AbCipTagDefinition def, AbCipTagPath parsedPath,
|
||||
DeviceState device, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||
{
|
||||
var baseParams = new AbCipTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsedPath.ToLibplctagName(),
|
||||
Timeout: _options.Timeout);
|
||||
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsedPath, baseParams);
|
||||
if (plan is null)
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.BadNotSupported, null, now);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureSliceRuntimeAsync(device, def.Name, plan.CreateParams, ct)
|
||||
.ConfigureAwait(false);
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status != 0)
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.MapLibplctagStatus(status), null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"libplctag status {status} reading slice {def.Name}");
|
||||
return;
|
||||
}
|
||||
|
||||
var values = AbCipArrayReadPlanner.Decode(plan, runtime);
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(values, AbCipStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null,
|
||||
AbCipStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Idempotently materialise a slice-read runtime. Slice runtimes share the device's
|
||||
/// <see cref="DeviceState.Runtimes"/> dict keyed by the tag's full name so repeated
|
||||
/// reads reuse the same libplctag handle without re-creating the native tag every poll.
|
||||
/// </summary>
|
||||
private async Task<IAbCipTagRuntime> EnsureSliceRuntimeAsync(
|
||||
DeviceState device, string tagName, AbCipTagCreateParams createParams, CancellationToken ct)
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(tagName, out var existing)) return existing;
|
||||
|
||||
var runtime = _tagFactory.Create(createParams);
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
}
|
||||
device.Runtimes[tagName] = runtime;
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Task #194 — perform one whole-UDT read on the parent tag, then decode each
|
||||
/// grouped member from the runtime's buffer at its computed byte offset. A per-group
|
||||
@@ -451,100 +660,184 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <summary>
|
||||
/// Write each request in order. Writes are NOT auto-retried by the driver — per plan
|
||||
/// decisions #44, #45, #143 the caller opts in via <see cref="AbCipTagDefinition.WriteIdempotent"/>
|
||||
/// and the resilience pipeline (layered above the driver) decides whether to replay.
|
||||
/// Non-writable configurations surface as <c>BadNotWritable</c>; type-conversion failures
|
||||
/// as <c>BadTypeMismatch</c>; transport errors as <c>BadCommunicationError</c>.
|
||||
/// Write each request in the batch. Writes are NOT auto-retried by the driver — per
|
||||
/// plan decisions #44, #45, #143 the caller opts in via
|
||||
/// <see cref="AbCipTagDefinition.WriteIdempotent"/> and the resilience pipeline (layered
|
||||
/// above the driver) decides whether to replay. Non-writable configurations surface as
|
||||
/// <c>BadNotWritable</c>; type-conversion failures as <c>BadTypeMismatch</c>; transport
|
||||
/// errors as <c>BadCommunicationError</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PR abcip-1.4 — multi-tag write packing. Writes are grouped by device via
|
||||
/// <see cref="AbCipMultiWritePlanner"/>. Devices whose family
|
||||
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> is <c>true</c> dispatch
|
||||
/// their packable writes concurrently so libplctag's native scheduler can coalesce them
|
||||
/// onto one CIP Multi-Service Packet (0x0A) per round-trip; Micro800 (no packing) still
|
||||
/// issues writes one-at-a-time. BOOL-within-DINT writes always go through the RMW path
|
||||
/// under a per-parent semaphore, regardless of the family flag, because two concurrent
|
||||
/// RMWs on the same DINT could lose one another's update. Per-tag StatusCodes are
|
||||
/// preserved in the caller's input order on partial failures.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
var results = new WriteResult[writes.Count];
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
var plans = AbCipMultiWritePlanner.Build(
|
||||
writes, _tagsByName, _devices,
|
||||
reportPreflight: (idx, code) => results[idx] = new WriteResult(code));
|
||||
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var def))
|
||||
if (!_devices.TryGetValue(plan.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!def.Writable || def.SafetyTag)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
foreach (var e in plan.Packable) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
foreach (var e in plan.BitRmw) results[e.OriginalIndex] = new WriteResult(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
|
||||
// Bit-RMW writes always serialise per-parent — never packed.
|
||||
foreach (var entry in plan.BitRmw)
|
||||
results[entry.OriginalIndex] = new WriteResult(
|
||||
await ExecuteBitRmwWriteAsync(device, entry, cancellationToken).ConfigureAwait(false));
|
||||
|
||||
// BOOL-within-DINT writes — per task #181, RMW against a parallel parent-DINT
|
||||
// runtime. Dispatching here keeps the normal EncodeValue path clean; the
|
||||
// per-parent lock prevents two concurrent bit writes to the same DINT from
|
||||
// losing one another's update.
|
||||
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
|
||||
if (plan.Packable.Count == 0) continue;
|
||||
|
||||
if (plan.Profile.SupportsRequestPacking && plan.Packable.Count > 1)
|
||||
{
|
||||
// Concurrent dispatch — libplctag's native scheduler packs same-connection writes
|
||||
// into one Multi-Service Packet when the family supports it.
|
||||
var tasks = new Task<(int idx, uint code)>[plan.Packable.Count];
|
||||
for (var i = 0; i < plan.Packable.Count; i++)
|
||||
{
|
||||
results[i] = new WriteResult(
|
||||
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
if (results[i].StatusCode == AbCipStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
continue;
|
||||
var entry = plan.Packable[i];
|
||||
tasks[i] = ExecutePackableWriteAsync(device, entry, cancellationToken);
|
||||
}
|
||||
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
results[i] = new WriteResult(status == 0
|
||||
? AbCipStatusMapper.Good
|
||||
: AbCipStatusMapper.MapLibplctagStatus(status));
|
||||
if (status == 0) _health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
var outcomes = await Task.WhenAll(tasks).ConfigureAwait(false);
|
||||
foreach (var (idx, code) in outcomes)
|
||||
results[idx] = new WriteResult(code);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
}
|
||||
catch (FormatException fe)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
||||
}
|
||||
catch (InvalidCastException ice)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
||||
}
|
||||
catch (OverflowException oe)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
// Single-write groups + Micro800 (SupportsRequestPacking=false) — sequential.
|
||||
foreach (var entry in plan.Packable)
|
||||
{
|
||||
var code = await ExecutePackableWriteAsync(device, entry, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
results[entry.OriginalIndex] = new WriteResult(code.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute one packable write — encode the value into the per-tag runtime, flush, and
|
||||
/// map the resulting libplctag status. Exception-to-StatusCode mapping mirrors the
|
||||
/// pre-1.4 per-tag loop so callers see no behaviour change for individual writes.
|
||||
/// </summary>
|
||||
private async Task<(int idx, uint code)> ExecutePackableWriteAsync(
|
||||
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
|
||||
{
|
||||
var def = entry.Definition;
|
||||
var w = entry.Request;
|
||||
var now = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||
runtime.EncodeValue(def.DataType, entry.ParsedPath?.BitIndex, w.Value);
|
||||
await runtime.WriteAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var status = runtime.GetStatus();
|
||||
if (status == 0)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.Good);
|
||||
}
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.MapLibplctagStatus(status));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.BadNotSupported);
|
||||
}
|
||||
catch (FormatException fe)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (InvalidCastException ice)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (OverflowException oe)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
return (entry.OriginalIndex, AbCipStatusMapper.BadCommunicationError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute one BOOL-within-DINT write through <see cref="WriteBitInDIntAsync"/>, with
|
||||
/// the same exception-mapping fan-out as the pre-1.4 per-tag loop. Bit RMWs cannot be
|
||||
/// packed because two concurrent writes against the same parent DINT would race their
|
||||
/// read-modify-write windows.
|
||||
/// </summary>
|
||||
private async Task<uint> ExecuteBitRmwWriteAsync(
|
||||
DeviceState device, AbCipMultiWritePlanner.ClassifiedWrite entry, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bit = entry.ParsedPath!.BitIndex!.Value;
|
||||
var code = await WriteBitInDIntAsync(device, entry.ParsedPath, bit, entry.Request.Value, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (code == AbCipStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
return code;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
return AbCipStatusMapper.BadNotSupported;
|
||||
}
|
||||
catch (FormatException fe)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
|
||||
return AbCipStatusMapper.BadTypeMismatch;
|
||||
}
|
||||
catch (InvalidCastException ice)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
|
||||
return AbCipStatusMapper.BadTypeMismatch;
|
||||
}
|
||||
catch (OverflowException oe)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
|
||||
return AbCipStatusMapper.BadOutOfRange;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
return AbCipStatusMapper.BadCommunicationError;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
|
||||
/// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
|
||||
@@ -633,7 +926,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
CipPath: device.ParsedAddress.CipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: parsed.ToLibplctagName(),
|
||||
Timeout: _options.Timeout));
|
||||
Timeout: _options.Timeout,
|
||||
StringMaxCapacity: def.DataType == AbCipDataType.String ? def.StringLength : null));
|
||||
try
|
||||
{
|
||||
await runtime.InitializeAsync(ct).ConfigureAwait(false);
|
||||
@@ -674,6 +968,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
await _discoverySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await DiscoverCoreAsync(builder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_discoverySemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-2.5 — operator-triggered rebrowse. Drops the cached UDT template shapes so
|
||||
/// the next read re-fetches them from the controller, then runs the same enumerator
|
||||
/// walk + builder fan-out that <see cref="DiscoverAsync"/> drives. Serialised against
|
||||
/// other rebrowse / discovery passes via <see cref="_discoverySemaphore"/> so two
|
||||
/// concurrent triggers don't double-issue the @tags read.
|
||||
/// </summary>
|
||||
public async Task RebrowseAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
await _discoverySemaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Stale template shapes can outlive a controller program-download, so a rebrowse
|
||||
// is the natural moment to drop them; subsequent UDT reads re-populate on demand.
|
||||
_templateCache.Clear();
|
||||
await DiscoverCoreAsync(builder, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_discoverySemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DiscoverCoreAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
var root = builder.Folder("AbCip", "AbCip");
|
||||
|
||||
foreach (var device in _options.Devices)
|
||||
@@ -694,10 +1025,31 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
if (tag.DataType == AbCipDataType.Structure && tag.Members is { Count: > 0 })
|
||||
{
|
||||
var udtFolder = deviceFolder.Folder(tag.Name, tag.Name);
|
||||
// PR abcip-2.6 — AOI-aware fan-out. When any member carries a non-Local
|
||||
// AoiQualifier the tag is treated as an AOI instance: Input / Output / InOut
|
||||
// members get grouped under sub-folders (Inputs/, Outputs/, InOut/) so the
|
||||
// browse tree visually matches Studio 5000's AOI parameter tabs. Plain UDT
|
||||
// tags (every member Local) retain the pre-2.6 flat layout under the parent
|
||||
// folder so existing browse paths stay stable.
|
||||
var hasDirectional = tag.Members.Any(m => m.AoiQualifier != AoiQualifier.Local);
|
||||
IAddressSpaceBuilder? inputsFolder = null;
|
||||
IAddressSpaceBuilder? outputsFolder = null;
|
||||
IAddressSpaceBuilder? inOutFolder = null;
|
||||
foreach (var member in tag.Members)
|
||||
{
|
||||
var parentFolder = udtFolder;
|
||||
if (hasDirectional)
|
||||
{
|
||||
parentFolder = member.AoiQualifier switch
|
||||
{
|
||||
AoiQualifier.Input => inputsFolder ??= udtFolder.Folder("Inputs", "Inputs"),
|
||||
AoiQualifier.Output => outputsFolder ??= udtFolder.Folder("Outputs", "Outputs"),
|
||||
AoiQualifier.InOut => inOutFolder ??= udtFolder.Folder("InOut", "InOut"),
|
||||
_ => udtFolder, // Local stays at the AOI root
|
||||
};
|
||||
}
|
||||
var memberFullName = $"{tag.Name}.{member.Name}";
|
||||
udtFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
||||
parentFolder.Variable(member.Name, member.Name, new DriverAttributeInfo(
|
||||
FullName: memberFullName,
|
||||
DriverDataType: member.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
@@ -707,7 +1059,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: member.WriteIdempotent));
|
||||
WriteIdempotent: member.WriteIdempotent,
|
||||
Description: member.Description));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -767,7 +1120,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
WriteIdempotent: tag.WriteIdempotent);
|
||||
WriteIdempotent: tag.WriteIdempotent,
|
||||
Description: tag.Description);
|
||||
|
||||
/// <summary>Count of registered devices — exposed for diagnostics + tests.</summary>
|
||||
internal int DeviceCount => _devices.Count;
|
||||
@@ -781,6 +1135,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
_discoverySemaphore.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -21,6 +21,37 @@ public sealed class AbCipDriverOptions
|
||||
/// <summary>Pre-declared tag map across all devices — AB discovery lands in PR 5.</summary>
|
||||
public IReadOnlyList<AbCipTagDefinition> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// L5K (Studio 5000 controller export) imports merged into <see cref="Tags"/> at
|
||||
/// <c>InitializeAsync</c>. Each entry points at one L5K file + the device whose tags it
|
||||
/// describes; the parser extracts <c>TAG</c> + <c>DATATYPE</c> blocks and produces
|
||||
/// <see cref="AbCipTagDefinition"/> records (alias tags + ExternalAccess=None tags
|
||||
/// skipped — see <see cref="Import.L5kIngest"/>). Pre-declared <see cref="Tags"/> entries
|
||||
/// win on <c>Name</c> conflicts so operators can override import results without
|
||||
/// editing the L5K source.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AbCipL5kImportOptions> L5kImports { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// L5X (Studio 5000 XML controller export) imports merged into <see cref="Tags"/> at
|
||||
/// <c>InitializeAsync</c>. Same shape and merge semantics as <see cref="L5kImports"/> —
|
||||
/// the entries differ only in source format. Pre-declared <see cref="Tags"/> entries win
|
||||
/// on <c>Name</c> conflicts; entries already produced by <see cref="L5kImports"/> also win
|
||||
/// so an L5X re-export of the same controller doesn't double-emit. See
|
||||
/// <see cref="Import.L5xParser"/> for the format-specific mechanics.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AbCipL5xImportOptions> L5xImports { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Kepware-format CSV imports merged into <see cref="Tags"/> at <c>InitializeAsync</c>.
|
||||
/// Same merge semantics as <see cref="L5kImports"/> / <see cref="L5xImports"/> —
|
||||
/// pre-declared <see cref="Tags"/> entries win on <c>Name</c> conflicts, and tags
|
||||
/// produced by earlier import collections (L5K → L5X → CSV in call order) also win
|
||||
/// so an Excel-edited copy of the same controller does not double-emit. See
|
||||
/// <see cref="Import.CsvTagImporter"/> for the column layout + parse rules.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AbCipCsvImportOptions> CsvImports { get; init; } = [];
|
||||
|
||||
/// <summary>Per-device probe settings. Falls back to defaults when omitted.</summary>
|
||||
public AbCipProbeOptions Probe { get; init; } = new();
|
||||
|
||||
@@ -92,6 +123,17 @@ public sealed record AbCipDeviceOptions(
|
||||
/// GuardLogix controller; non-safety writes violate the safety-partition isolation and are
|
||||
/// rejected by the PLC anyway. Surfaces the intent explicitly instead of relying on the
|
||||
/// write attempt failing at runtime.</param>
|
||||
/// <param name="StringLength">Capacity of the DATA character array on a Logix STRING / STRINGnn
|
||||
/// UDT — 82 for the stock <c>STRING</c>, 20/40/80/etc for user-defined <c>STRING_20</c>,
|
||||
/// <c>STRING_40</c>, <c>STRING_80</c> variants. Threads through libplctag's
|
||||
/// <c>str_max_capacity</c> attribute so the wrapper allocates the correct backing buffer
|
||||
/// and <c>GetString</c> / <c>SetString</c> truncate at the right boundary. <c>null</c>
|
||||
/// keeps libplctag's default 82-byte STRING behaviour for back-compat. Ignored for
|
||||
/// non-<see cref="AbCipDataType.String"/> types.</param>
|
||||
/// <param name="Description">Tag description carried from the L5K/L5X export (or set explicitly
|
||||
/// in pre-declared config). Surfaces as the OPC UA <c>Description</c> attribute on the
|
||||
/// produced Variable node so SCADA / engineering clients see the comment from the source
|
||||
/// project. <c>null</c> leaves Description unset, matching pre-2.3 behaviour.</param>
|
||||
public sealed record AbCipTagDefinition(
|
||||
string Name,
|
||||
string DeviceHostAddress,
|
||||
@@ -100,7 +142,9 @@ public sealed record AbCipTagDefinition(
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false,
|
||||
IReadOnlyList<AbCipStructureMember>? Members = null,
|
||||
bool SafetyTag = false);
|
||||
bool SafetyTag = false,
|
||||
int? StringLength = null,
|
||||
string? Description = null);
|
||||
|
||||
/// <summary>
|
||||
/// One declared member of a UDT tag. Name is the member identifier on the PLC (e.g. <c>Speed</c>,
|
||||
@@ -108,11 +152,92 @@ public sealed record AbCipTagDefinition(
|
||||
/// <see cref="AbCipTagDefinition"/>. Declaration-driven — the real CIP Template Object reader
|
||||
/// (class 0x6C) that would auto-discover member layouts lands as a follow-up PR.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><see cref="Description"/> carries the per-member comment from L5K/L5X UDT definitions so
|
||||
/// the OPC UA Variable nodes produced for individual members surface their descriptions too,
|
||||
/// not just the top-level tag.</para>
|
||||
/// <para>PR abcip-2.6 — <see cref="AoiQualifier"/> tags AOI parameters as Input / Output /
|
||||
/// InOut / Local. Plain UDT members default to <see cref="AoiQualifier.Local"/>. Discovery
|
||||
/// groups Input / Output / InOut members under sub-folders so an AOI-typed tag fans out as
|
||||
/// <c>Tag/Inputs/...</c>, <c>Tag/Outputs/...</c>, <c>Tag/InOut/...</c> while Local stays at the
|
||||
/// UDT root — matching how AOIs visually present in Studio 5000.</para>
|
||||
/// </remarks>
|
||||
public sealed record AbCipStructureMember(
|
||||
string Name,
|
||||
AbCipDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
int? StringLength = null,
|
||||
string? Description = null,
|
||||
AoiQualifier AoiQualifier = AoiQualifier.Local);
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-2.6 — directional qualifier for AOI parameters. Surfaces the Studio 5000
|
||||
/// <c>Usage</c> attribute (<c>Input</c> / <c>Output</c> / <c>InOut</c>) so discovery can group
|
||||
/// AOI members into sub-folders and downstream consumers can reason about parameter direction.
|
||||
/// Plain UDT members (non-AOI types) default to <see cref="Local"/>, which keeps them at the
|
||||
/// UDT root + indicates they are internal storage rather than a directional parameter.
|
||||
/// </summary>
|
||||
public enum AoiQualifier
|
||||
{
|
||||
/// <summary>UDT member or AOI local tag — non-directional, browsed at the parent's root.</summary>
|
||||
Local,
|
||||
|
||||
/// <summary>AOI input parameter — written by the caller, read by the AOI body.</summary>
|
||||
Input,
|
||||
|
||||
/// <summary>AOI output parameter — written by the AOI body, read by the caller.</summary>
|
||||
Output,
|
||||
|
||||
/// <summary>AOI bidirectional parameter — passed by reference, both sides may read/write.</summary>
|
||||
InOut,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One L5K-import entry. Either <see cref="FilePath"/> or <see cref="InlineText"/> must be
|
||||
/// set (FilePath wins when both supplied — useful for tests that pre-load fixtures into
|
||||
/// options without touching disk).
|
||||
/// </summary>
|
||||
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
|
||||
/// <param name="FilePath">On-disk path to a <c>*.L5K</c> export. Loaded eagerly at InitializeAsync.</param>
|
||||
/// <param name="InlineText">Pre-loaded L5K body — used by tests + Admin UI uploads.</param>
|
||||
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions
|
||||
/// when ingesting multiple files into one driver instance.</param>
|
||||
public sealed record AbCipL5kImportOptions(
|
||||
string DeviceHostAddress,
|
||||
string? FilePath = null,
|
||||
string? InlineText = null,
|
||||
string NamePrefix = "");
|
||||
|
||||
/// <summary>
|
||||
/// One L5X-import entry. Mirrors <see cref="AbCipL5kImportOptions"/> field-for-field — the
|
||||
/// two are kept as distinct types so configuration JSON makes the source format explicit
|
||||
/// (an L5X file under an <c>L5kImports</c> entry would parse-fail confusingly otherwise).
|
||||
/// </summary>
|
||||
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
|
||||
/// <param name="FilePath">On-disk path to a <c>*.L5X</c> XML export. Loaded eagerly at InitializeAsync.</param>
|
||||
/// <param name="InlineText">Pre-loaded L5X body — used by tests + Admin UI uploads.</param>
|
||||
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions
|
||||
/// when ingesting multiple files into one driver instance.</param>
|
||||
public sealed record AbCipL5xImportOptions(
|
||||
string DeviceHostAddress,
|
||||
string? FilePath = null,
|
||||
string? InlineText = null,
|
||||
string NamePrefix = "");
|
||||
|
||||
/// <summary>
|
||||
/// One Kepware-format CSV import entry. Field shape mirrors <see cref="AbCipL5kImportOptions"/>
|
||||
/// so configuration JSON stays consistent across the three import sources.
|
||||
/// </summary>
|
||||
/// <param name="DeviceHostAddress">Target device <c>HostAddress</c> tags from this file are bound to.</param>
|
||||
/// <param name="FilePath">On-disk path to a Kepware-format <c>*.csv</c>. Loaded eagerly at InitializeAsync.</param>
|
||||
/// <param name="InlineText">Pre-loaded CSV body — used by tests + Admin UI uploads.</param>
|
||||
/// <param name="NamePrefix">Optional prefix prepended to imported tag names to avoid collisions.</param>
|
||||
public sealed record AbCipCsvImportOptions(
|
||||
string DeviceHostAddress,
|
||||
string? FilePath = null,
|
||||
string? InlineText = null,
|
||||
string NamePrefix = "");
|
||||
|
||||
/// <summary>Which AB PLC family the device is — selects the profile applied to connection params.</summary>
|
||||
public enum AbCipPlcFamily
|
||||
|
||||
112
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiWritePlanner.cs
Normal file
112
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipMultiWritePlanner.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-1.4 — multi-tag write planner. Groups a batch of <see cref="WriteRequest"/>s by
|
||||
/// device so the driver can submit one round of writes per device instead of looping
|
||||
/// strictly serially across the whole batch. Honours the per-family
|
||||
/// <see cref="AbCipPlcFamilyProfile.SupportsRequestPacking"/> flag: families that support
|
||||
/// CIP request packing (ControlLogix / CompactLogix / GuardLogix) issue their writes in
|
||||
/// parallel so libplctag's internal scheduler can coalesce them onto one Multi-Service
|
||||
/// Packet (0x0A); Micro800 (no request packing) falls back to per-tag sequential writes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The libplctag .NET wrapper exposes one CIP service per <c>Tag</c> instance and does
|
||||
/// not surface Multi-Service Packet construction at the API surface — but the underlying
|
||||
/// native library packs concurrent operations against the same connection automatically
|
||||
/// when the family's protocol supports it. Issuing the writes concurrently per device
|
||||
/// therefore gives us the round-trip reduction described in #228 without having to drop to
|
||||
/// raw CIP, while still letting us short-circuit packing on Micro800 where it would be
|
||||
/// unsafe.</para>
|
||||
///
|
||||
/// <para>Bit-RMW writes (BOOL-with-bitIndex against a DINT parent) are excluded from
|
||||
/// packing here because they need a serialised read-modify-write under the per-parent
|
||||
/// <c>SemaphoreSlim</c> in <see cref="AbCipDriver.WriteBitInDIntAsync"/>. Packing two RMWs
|
||||
/// on the same DINT would risk losing one another's update.</para>
|
||||
/// </remarks>
|
||||
internal static class AbCipMultiWritePlanner
|
||||
{
|
||||
/// <summary>
|
||||
/// One classified entry in the input batch. <see cref="OriginalIndex"/> preserves the
|
||||
/// caller's ordering so per-tag <c>StatusCode</c> fan-out lands at the right slot in
|
||||
/// the result array. <see cref="IsBitRmw"/> routes the entry through the RMW path even
|
||||
/// when the device supports packing.
|
||||
/// </summary>
|
||||
internal readonly record struct ClassifiedWrite(
|
||||
int OriginalIndex,
|
||||
WriteRequest Request,
|
||||
AbCipTagDefinition Definition,
|
||||
AbCipTagPath? ParsedPath,
|
||||
bool IsBitRmw);
|
||||
|
||||
/// <summary>
|
||||
/// One device's plan slice. <see cref="Packable"/> entries can be issued concurrently;
|
||||
/// <see cref="BitRmw"/> entries must go through the RMW path one-at-a-time per parent
|
||||
/// DINT.
|
||||
/// </summary>
|
||||
internal sealed class DevicePlan
|
||||
{
|
||||
public required string DeviceHostAddress { get; init; }
|
||||
public required AbCipPlcFamilyProfile Profile { get; init; }
|
||||
public List<ClassifiedWrite> Packable { get; } = new();
|
||||
public List<ClassifiedWrite> BitRmw { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the per-device plan list. Entries are visited in input order so the resulting
|
||||
/// plan's traversal preserves caller ordering within each device. Entries that fail
|
||||
/// resolution (unknown reference, non-writable tag, unknown device) are reported via
|
||||
/// <paramref name="reportPreflight"/> with the appropriate StatusCode and excluded from
|
||||
/// the plan.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<DevicePlan> Build(
|
||||
IReadOnlyList<WriteRequest> writes,
|
||||
IReadOnlyDictionary<string, AbCipTagDefinition> tagsByName,
|
||||
IReadOnlyDictionary<string, AbCipDriver.DeviceState> devices,
|
||||
Action<int, uint> reportPreflight)
|
||||
{
|
||||
var plans = new Dictionary<string, DevicePlan>(StringComparer.OrdinalIgnoreCase);
|
||||
var order = new List<DevicePlan>();
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!tagsByName.TryGetValue(w.FullReference, out var def))
|
||||
{
|
||||
reportPreflight(i, AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!def.Writable || def.SafetyTag)
|
||||
{
|
||||
reportPreflight(i, AbCipStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
{
|
||||
reportPreflight(i, AbCipStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!plans.TryGetValue(def.DeviceHostAddress, out var plan))
|
||||
{
|
||||
plan = new DevicePlan
|
||||
{
|
||||
DeviceHostAddress = def.DeviceHostAddress,
|
||||
Profile = device.Profile,
|
||||
};
|
||||
plans[def.DeviceHostAddress] = plan;
|
||||
order.Add(plan);
|
||||
}
|
||||
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath);
|
||||
var isBitRmw = def.DataType == AbCipDataType.Bool && parsed?.BitIndex is int;
|
||||
var entry = new ClassifiedWrite(i, w, def, parsed, isBitRmw);
|
||||
if (isBitRmw) plan.BitRmw.Add(entry);
|
||||
else plan.Packable.Add(entry);
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
public sealed record AbCipTagPath(
|
||||
string? ProgramScope,
|
||||
IReadOnlyList<AbCipTagPathSegment> Segments,
|
||||
int? BitIndex)
|
||||
int? BitIndex,
|
||||
AbCipTagPathSlice? Slice = null)
|
||||
{
|
||||
/// <summary>Rebuild the canonical Logix tag string.</summary>
|
||||
public string ToLibplctagName()
|
||||
@@ -37,10 +38,39 @@ public sealed record AbCipTagPath(
|
||||
if (seg.Subscripts.Count > 0)
|
||||
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
|
||||
}
|
||||
if (Slice is not null) buf.Append('[').Append(Slice.Start).Append("..").Append(Slice.End).Append(']');
|
||||
if (BitIndex is not null) buf.Append('.').Append(BitIndex.Value);
|
||||
return buf.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logix-symbol form for issuing a single libplctag tag-create that reads the slice as a
|
||||
/// contiguous buffer — i.e. the bare array name (with the start subscript) without the
|
||||
/// <c>..End</c> suffix. The driver pairs this with <see cref="AbCipTagCreateParams.ElementCount"/>
|
||||
/// = <see cref="AbCipTagPathSlice.Count"/> to issue a single Rockwell array read.
|
||||
/// </summary>
|
||||
public string ToLibplctagSliceArrayName()
|
||||
{
|
||||
if (Slice is null) return ToLibplctagName();
|
||||
var buf = new System.Text.StringBuilder();
|
||||
if (ProgramScope is not null)
|
||||
buf.Append("Program:").Append(ProgramScope).Append('.');
|
||||
|
||||
for (var i = 0; i < Segments.Count; i++)
|
||||
{
|
||||
if (i > 0) buf.Append('.');
|
||||
var seg = Segments[i];
|
||||
buf.Append(seg.Name);
|
||||
if (seg.Subscripts.Count > 0)
|
||||
buf.Append('[').Append(string.Join(",", seg.Subscripts)).Append(']');
|
||||
}
|
||||
// Anchor the read at the slice start; libplctag treats Name=Tag[0] + ElementCount=N as
|
||||
// "read N consecutive elements starting at index 0", which is the exact Rockwell
|
||||
// array-read semantic this PR is wiring up.
|
||||
buf.Append('[').Append(Slice.Start).Append(']');
|
||||
return buf.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a Logix-symbolic tag reference. Returns <c>null</c> on a shape the parser
|
||||
/// doesn't support — the driver surfaces that as a config-validation error rather than
|
||||
@@ -91,8 +121,10 @@ public sealed record AbCipTagPath(
|
||||
}
|
||||
|
||||
var segments = new List<AbCipTagPathSegment>(parts.Count);
|
||||
foreach (var part in parts)
|
||||
AbCipTagPathSlice? slice = null;
|
||||
for (var partIdx = 0; partIdx < parts.Count; partIdx++)
|
||||
{
|
||||
var part = parts[partIdx];
|
||||
var bracketIdx = part.IndexOf('[');
|
||||
if (bracketIdx < 0)
|
||||
{
|
||||
@@ -104,6 +136,25 @@ public sealed record AbCipTagPath(
|
||||
var name = part[..bracketIdx];
|
||||
if (!IsValidIdent(name)) return null;
|
||||
var inner = part[(bracketIdx + 1)..^1];
|
||||
|
||||
// Slice syntax `[N..M]` — only allowed on the LAST segment, must not coexist with
|
||||
// multi-dim subscripts, must not be combined with bit-index, and requires M >= N.
|
||||
// Any other shape is rejected so callers see a config-validation error rather than
|
||||
// the driver attempting a best-effort scalar read.
|
||||
if (inner.Contains(".."))
|
||||
{
|
||||
if (partIdx != parts.Count - 1) return null; // slice + sub-element
|
||||
if (bitIndex is not null) return null; // slice + bit index
|
||||
if (inner.Contains(',')) return null; // slice cannot be multi-dim
|
||||
var parts2 = inner.Split("..", 2, StringSplitOptions.None);
|
||||
if (parts2.Length != 2) return null;
|
||||
if (!int.TryParse(parts2[0], out var sliceStart) || sliceStart < 0) return null;
|
||||
if (!int.TryParse(parts2[1], out var sliceEnd) || sliceEnd < sliceStart) return null;
|
||||
slice = new AbCipTagPathSlice(sliceStart, sliceEnd);
|
||||
segments.Add(new AbCipTagPathSegment(name, []));
|
||||
continue;
|
||||
}
|
||||
|
||||
var subs = new List<int>();
|
||||
foreach (var tok in inner.Split(','))
|
||||
{
|
||||
@@ -115,7 +166,7 @@ public sealed record AbCipTagPath(
|
||||
}
|
||||
if (segments.Count == 0) return null;
|
||||
|
||||
return new AbCipTagPath(programScope, segments, bitIndex);
|
||||
return new AbCipTagPath(programScope, segments, bitIndex, slice);
|
||||
}
|
||||
|
||||
private static bool IsValidIdent(string s)
|
||||
@@ -130,3 +181,15 @@ public sealed record AbCipTagPath(
|
||||
|
||||
/// <summary>One path segment: a member name plus any numeric subscripts.</summary>
|
||||
public sealed record AbCipTagPathSegment(string Name, IReadOnlyList<int> Subscripts);
|
||||
|
||||
/// <summary>
|
||||
/// Inclusive-on-both-ends array slice carried on the trailing segment of an
|
||||
/// <see cref="AbCipTagPath"/>. <c>Tag[0..15]</c> parses to <c>Start=0, End=15</c>; the
|
||||
/// planner pairs this with libplctag's <c>ElementCount</c> attribute to issue a single
|
||||
/// Rockwell array read covering <c>End - Start + 1</c> elements.
|
||||
/// </summary>
|
||||
public sealed record AbCipTagPathSlice(int Start, int End)
|
||||
{
|
||||
/// <summary>Total element count covered by the slice (inclusive both ends).</summary>
|
||||
public int Count => End - Start + 1;
|
||||
}
|
||||
|
||||
@@ -65,10 +65,20 @@ public interface IAbCipTagFactory
|
||||
/// <param name="LibplctagPlcAttribute">libplctag <c>plc=...</c> attribute, per family profile.</param>
|
||||
/// <param name="TagName">Logix symbolic tag name as emitted by <see cref="AbCipTagPath.ToLibplctagName"/>.</param>
|
||||
/// <param name="Timeout">libplctag operation timeout (applies to Initialize / Read / Write).</param>
|
||||
/// <param name="StringMaxCapacity">Optional Logix STRINGnn DATA-array capacity (e.g. 20 / 40 / 80
|
||||
/// for <c>STRING_20</c> / <c>STRING_40</c> / <c>STRING_80</c> UDTs). Threads through libplctag's
|
||||
/// <c>str_max_capacity</c> attribute. <c>null</c> keeps libplctag's default 82-byte STRING
|
||||
/// behaviour for back-compat.</param>
|
||||
/// <param name="ElementCount">Optional libplctag <c>ElementCount</c> override — set to <c>N</c>
|
||||
/// to issue a Rockwell array read covering <c>N</c> consecutive elements starting at the
|
||||
/// subscripted index in <see cref="TagName"/>. Drives PR abcip-1.3 array-slice support;
|
||||
/// <c>null</c> leaves libplctag's default scalar-element behaviour for back-compat.</param>
|
||||
public sealed record AbCipTagCreateParams(
|
||||
string Gateway,
|
||||
int Port,
|
||||
string CipPath,
|
||||
string LibplctagPlcAttribute,
|
||||
string TagName,
|
||||
TimeSpan Timeout);
|
||||
TimeSpan Timeout,
|
||||
int? StringMaxCapacity = null,
|
||||
int? ElementCount = null);
|
||||
|
||||
99
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs
Normal file
99
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagExporter.cs
Normal file
@@ -0,0 +1,99 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Render an enumerable of <see cref="AbCipTagDefinition"/> as a Kepware-format CSV
|
||||
/// document. Emits the header expected by <see cref="CsvTagImporter"/> so the importer
|
||||
/// and exporter form a complete round-trip path: load → export → reparse → identical
|
||||
/// entries (modulo unknown-type tags, which export as <c>STRING</c> and reimport as
|
||||
/// <see cref="AbCipDataType.Structure"/> per the importer's fall-through rule).
|
||||
/// </summary>
|
||||
public static class CsvTagExporter
|
||||
{
|
||||
public static readonly IReadOnlyList<string> KepwareColumns =
|
||||
[
|
||||
"Tag Name",
|
||||
"Address",
|
||||
"Data Type",
|
||||
"Respect Data Type",
|
||||
"Client Access",
|
||||
"Scan Rate",
|
||||
"Description",
|
||||
"Scaling",
|
||||
];
|
||||
|
||||
/// <summary>Write the tag list to <paramref name="writer"/> in Kepware CSV format.</summary>
|
||||
public static void Write(IEnumerable<AbCipTagDefinition> tags, TextWriter writer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tags);
|
||||
ArgumentNullException.ThrowIfNull(writer);
|
||||
|
||||
writer.WriteLine(string.Join(",", KepwareColumns.Select(EscapeField)));
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
var fields = new[]
|
||||
{
|
||||
tag.Name ?? string.Empty,
|
||||
tag.TagPath ?? string.Empty,
|
||||
FormatDataType(tag.DataType),
|
||||
"1", // Respect Data Type — Kepware EX default.
|
||||
tag.Writable ? "Read/Write" : "Read Only",
|
||||
"100", // Scan Rate (ms) — placeholder default.
|
||||
tag.Description ?? string.Empty,
|
||||
"None", // Scaling — driver doesn't apply scaling.
|
||||
};
|
||||
writer.WriteLine(string.Join(",", fields.Select(EscapeField)));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Render the tag list to a string.</summary>
|
||||
public static string ToCsv(IEnumerable<AbCipTagDefinition> tags)
|
||||
{
|
||||
using var sw = new StringWriter(CultureInfo.InvariantCulture);
|
||||
Write(tags, sw);
|
||||
return sw.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Write the tag list to <paramref name="path"/> as UTF-8 (no BOM).</summary>
|
||||
public static void WriteFile(IEnumerable<AbCipTagDefinition> tags, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
using var sw = new StreamWriter(path, append: false, new UTF8Encoding(false));
|
||||
Write(tags, sw);
|
||||
}
|
||||
|
||||
private static string FormatDataType(AbCipDataType t) => t switch
|
||||
{
|
||||
AbCipDataType.Bool => "BOOL",
|
||||
AbCipDataType.SInt => "SINT",
|
||||
AbCipDataType.Int => "INT",
|
||||
AbCipDataType.DInt => "DINT",
|
||||
AbCipDataType.LInt => "LINT",
|
||||
AbCipDataType.USInt => "USINT",
|
||||
AbCipDataType.UInt => "UINT",
|
||||
AbCipDataType.UDInt => "UDINT",
|
||||
AbCipDataType.ULInt => "ULINT",
|
||||
AbCipDataType.Real => "REAL",
|
||||
AbCipDataType.LReal => "LREAL",
|
||||
AbCipDataType.String => "STRING",
|
||||
AbCipDataType.Dt => "DT",
|
||||
AbCipDataType.Structure => "STRING", // Surface UDT-typed tags as STRING — Kepware has no UDT cell.
|
||||
_ => "STRING",
|
||||
};
|
||||
|
||||
/// <summary>Quote a field if it contains comma, quote, CR, or LF; escape embedded quotes by doubling.</summary>
|
||||
private static string EscapeField(string value)
|
||||
{
|
||||
value ??= string.Empty;
|
||||
var needsQuotes =
|
||||
value.IndexOf(',') >= 0 ||
|
||||
value.IndexOf('"') >= 0 ||
|
||||
value.IndexOf('\r') >= 0 ||
|
||||
value.IndexOf('\n') >= 0;
|
||||
if (!needsQuotes) return value;
|
||||
return "\"" + value.Replace("\"", "\"\"") + "\"";
|
||||
}
|
||||
}
|
||||
226
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs
Normal file
226
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/CsvTagImporter.cs
Normal file
@@ -0,0 +1,226 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Parse a Kepware-format AB CIP tag CSV into <see cref="AbCipTagDefinition"/> entries.
|
||||
/// The expected column layout matches the Kepware EX tag-export shape so operators can
|
||||
/// round-trip tags through Excel without re-keying:
|
||||
/// <c>Tag Name, Address, Data Type, Respect Data Type, Client Access, Scan Rate,
|
||||
/// Description, Scaling</c>. The first non-blank, non-comment row is treated as the
|
||||
/// header — column order is honoured by name lookup, so reorderings out of Excel still
|
||||
/// work. Blank rows + rows whose first cell starts with a Kepware section marker
|
||||
/// (<c>;</c> / <c>#</c>) are skipped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Mapping: <c>Tag Name</c> → <see cref="AbCipTagDefinition.Name"/>;
|
||||
/// <c>Address</c> → <see cref="AbCipTagDefinition.TagPath"/>;
|
||||
/// <c>Data Type</c> → <see cref="AbCipTagDefinition.DataType"/> (Logix atomic name —
|
||||
/// BOOL/SINT/INT/DINT/REAL/STRING/...; unknown values fall through as
|
||||
/// <see cref="AbCipDataType.Structure"/> the same way <see cref="L5kIngest"/> handles
|
||||
/// unknown types);
|
||||
/// <c>Description</c> → <see cref="AbCipTagDefinition.Description"/>;
|
||||
/// <c>Client Access</c> → <see cref="AbCipTagDefinition.Writable"/>: any value
|
||||
/// containing <c>W</c> (case-insensitive) is treated as Read/Write; everything else
|
||||
/// is Read-Only.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// CSV semantics are RFC-4180-ish: double-quoted fields support embedded commas, line
|
||||
/// breaks, and escaped quotes (<c>""</c>). The parser is single-pass + deliberately
|
||||
/// narrow — Kepware's exporter does not produce anything more exotic.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class CsvTagImporter
|
||||
{
|
||||
/// <summary>Default device host address applied to every imported tag.</summary>
|
||||
public string DefaultDeviceHostAddress { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Optional prefix prepended to each imported tag's name. Default empty.</summary>
|
||||
public string NamePrefix { get; init; } = string.Empty;
|
||||
|
||||
public CsvTagImportResult Import(string csvText)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(csvText);
|
||||
if (string.IsNullOrWhiteSpace(DefaultDeviceHostAddress))
|
||||
throw new InvalidOperationException(
|
||||
$"{nameof(CsvTagImporter)}.{nameof(DefaultDeviceHostAddress)} must be set before {nameof(Import)} is called — every imported tag needs a target device.");
|
||||
|
||||
var rows = CsvReader.ReadAll(csvText);
|
||||
var tags = new List<AbCipTagDefinition>();
|
||||
var skippedBlank = 0;
|
||||
Dictionary<string, int>? header = null;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (row.Count == 0 || row.All(string.IsNullOrWhiteSpace))
|
||||
{
|
||||
skippedBlank++;
|
||||
continue;
|
||||
}
|
||||
var first = row[0].TrimStart();
|
||||
if (first.StartsWith(';') || first.StartsWith('#'))
|
||||
{
|
||||
skippedBlank++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header is null)
|
||||
{
|
||||
header = BuildHeader(row);
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = GetCell(row, header, "Tag Name");
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
skippedBlank++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var address = GetCell(row, header, "Address");
|
||||
var dataTypeText = GetCell(row, header, "Data Type");
|
||||
var description = GetCell(row, header, "Description");
|
||||
var clientAccess = GetCell(row, header, "Client Access");
|
||||
|
||||
var dataType = ParseDataType(dataTypeText);
|
||||
var writable = !string.IsNullOrEmpty(clientAccess)
|
||||
&& clientAccess.IndexOf('W', StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
|
||||
tags.Add(new AbCipTagDefinition(
|
||||
Name: string.IsNullOrEmpty(NamePrefix) ? name : $"{NamePrefix}{name}",
|
||||
DeviceHostAddress: DefaultDeviceHostAddress,
|
||||
TagPath: string.IsNullOrEmpty(address) ? name : address,
|
||||
DataType: dataType,
|
||||
Writable: writable,
|
||||
Description: string.IsNullOrEmpty(description) ? null : description));
|
||||
}
|
||||
|
||||
return new CsvTagImportResult(tags, skippedBlank);
|
||||
}
|
||||
|
||||
public CsvTagImportResult ImportFile(string path) =>
|
||||
Import(File.ReadAllText(path, Encoding.UTF8));
|
||||
|
||||
private static Dictionary<string, int> BuildHeader(IReadOnlyList<string> row)
|
||||
{
|
||||
var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < row.Count; i++)
|
||||
{
|
||||
var key = row[i]?.Trim() ?? string.Empty;
|
||||
if (key.Length > 0 && !dict.ContainsKey(key))
|
||||
dict[key] = i;
|
||||
}
|
||||
return dict;
|
||||
}
|
||||
|
||||
private static string GetCell(IReadOnlyList<string> row, Dictionary<string, int> header, string column)
|
||||
{
|
||||
if (!header.TryGetValue(column, out var idx)) return string.Empty;
|
||||
if (idx < 0 || idx >= row.Count) return string.Empty;
|
||||
return row[idx]?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static AbCipDataType ParseDataType(string s) =>
|
||||
s?.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"BOOL" or "BIT" => AbCipDataType.Bool,
|
||||
"SINT" or "BYTE" => AbCipDataType.SInt,
|
||||
"INT" or "WORD" or "SHORT" => AbCipDataType.Int,
|
||||
"DINT" or "DWORD" or "LONG" => AbCipDataType.DInt,
|
||||
"LINT" => AbCipDataType.LInt,
|
||||
"USINT" => AbCipDataType.USInt,
|
||||
"UINT" => AbCipDataType.UInt,
|
||||
"UDINT" => AbCipDataType.UDInt,
|
||||
"ULINT" => AbCipDataType.ULInt,
|
||||
"REAL" or "FLOAT" => AbCipDataType.Real,
|
||||
"LREAL" or "DOUBLE" => AbCipDataType.LReal,
|
||||
"STRING" => AbCipDataType.String,
|
||||
"DT" or "DATETIME" or "DATE" => AbCipDataType.Dt,
|
||||
_ => AbCipDataType.Structure,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Result of <see cref="CsvTagImporter.Import"/>.</summary>
|
||||
public sealed record CsvTagImportResult(
|
||||
IReadOnlyList<AbCipTagDefinition> Tags,
|
||||
int SkippedBlankCount);
|
||||
|
||||
/// <summary>
|
||||
/// Tiny RFC-4180-ish CSV reader. Supports double-quoted fields, escaped <c>""</c>
|
||||
/// quotes, and embedded line breaks inside quotes. Internal because the importer +
|
||||
/// exporter are the only two callers and we don't want to add a CSV dep.
|
||||
/// </summary>
|
||||
internal static class CsvReader
|
||||
{
|
||||
public static List<List<string>> ReadAll(string text)
|
||||
{
|
||||
var rows = new List<List<string>>();
|
||||
var row = new List<string>();
|
||||
var field = new StringBuilder();
|
||||
var inQuotes = false;
|
||||
|
||||
for (var i = 0; i < text.Length; i++)
|
||||
{
|
||||
var c = text[i];
|
||||
if (inQuotes)
|
||||
{
|
||||
if (c == '"')
|
||||
{
|
||||
if (i + 1 < text.Length && text[i + 1] == '"')
|
||||
{
|
||||
field.Append('"');
|
||||
i++;
|
||||
}
|
||||
else
|
||||
{
|
||||
inQuotes = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
field.Append(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (c)
|
||||
{
|
||||
case '"':
|
||||
inQuotes = true;
|
||||
break;
|
||||
case ',':
|
||||
row.Add(field.ToString());
|
||||
field.Clear();
|
||||
break;
|
||||
case '\r':
|
||||
// Swallow CR — handle CRLF and lone CR alike.
|
||||
row.Add(field.ToString());
|
||||
field.Clear();
|
||||
rows.Add(row);
|
||||
row = new List<string>();
|
||||
if (i + 1 < text.Length && text[i + 1] == '\n') i++;
|
||||
break;
|
||||
case '\n':
|
||||
row.Add(field.ToString());
|
||||
field.Clear();
|
||||
rows.Add(row);
|
||||
row = new List<string>();
|
||||
break;
|
||||
default:
|
||||
field.Append(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (field.Length > 0 || row.Count > 0)
|
||||
{
|
||||
row.Add(field.ToString());
|
||||
rows.Add(row);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/IL5kSource.cs
Normal file
29
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/IL5kSource.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over an L5K text source so the parser can consume strings, files, or streams
|
||||
/// without coupling to <see cref="System.IO"/>. Implementations return the full text in a
|
||||
/// single call — L5K files are typically <10 MB even for large controllers, and the parser
|
||||
/// needs random access to handle nested DATATYPE/TAG blocks regardless.
|
||||
/// </summary>
|
||||
public interface IL5kSource
|
||||
{
|
||||
/// <summary>Reads the full L5K body as a string.</summary>
|
||||
string ReadAll();
|
||||
}
|
||||
|
||||
/// <summary>String-backed source — used by tests + when the L5K body is loaded elsewhere.</summary>
|
||||
public sealed class StringL5kSource : IL5kSource
|
||||
{
|
||||
private readonly string _text;
|
||||
public StringL5kSource(string text) => _text = text ?? throw new ArgumentNullException(nameof(text));
|
||||
public string ReadAll() => _text;
|
||||
}
|
||||
|
||||
/// <summary>File-backed source — used by Admin / driver init to load <c>*.L5K</c> exports.</summary>
|
||||
public sealed class FileL5kSource : IL5kSource
|
||||
{
|
||||
private readonly string _path;
|
||||
public FileL5kSource(string path) => _path = path ?? throw new ArgumentNullException(nameof(path));
|
||||
public string ReadAll() => System.IO.File.ReadAllText(_path);
|
||||
}
|
||||
161
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs
Normal file
161
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kIngest.cs
Normal file
@@ -0,0 +1,161 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a parsed <see cref="L5kDocument"/> into <see cref="AbCipTagDefinition"/> entries
|
||||
/// ready to be merged into <see cref="AbCipDriverOptions.Tags"/>. UDT definitions become
|
||||
/// <see cref="AbCipStructureMember"/> lists keyed by data-type name; tags whose
|
||||
/// <see cref="L5kTag.DataType"/> matches a known UDT get those members attached so the
|
||||
/// discovery code can fan out the structure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <strong>Alias tags are skipped</strong> — when <see cref="L5kTag.AliasFor"/> is
|
||||
/// non-null the entry is dropped at ingest. Surfacing both the alias + its target
|
||||
/// creates duplicate Variables in the OPC UA address space (Kepware's L5K importer
|
||||
/// takes the same approach for this reason; the alias target is the single source of
|
||||
/// truth for storage).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Tags with <c>ExternalAccess := None</c> are skipped</strong> — the controller
|
||||
/// actively rejects external reads/writes, so emitting them as Variables would just
|
||||
/// produce permanent BadCommunicationError. <c>Read Only</c> maps to <c>Writable=false</c>;
|
||||
/// <c>Read/Write</c> (or absent) maps to <c>Writable=true</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Unknown data-type names (not atomic + not a parsed UDT) fall through as
|
||||
/// <see cref="AbCipDataType.Structure"/> with no member layout — discovery can still
|
||||
/// expose them as black-box variables and the operator can pin them via dotted paths.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class L5kIngest
|
||||
{
|
||||
/// <summary>Default device host address applied to every imported tag.</summary>
|
||||
public string DefaultDeviceHostAddress { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional prefix prepended to imported tag names — useful when ingesting multiple
|
||||
/// L5K exports into one driver instance to avoid name collisions. Default empty.
|
||||
/// </summary>
|
||||
public string NamePrefix { get; init; } = string.Empty;
|
||||
|
||||
public L5kIngestResult Ingest(L5kDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
if (string.IsNullOrWhiteSpace(DefaultDeviceHostAddress))
|
||||
throw new InvalidOperationException(
|
||||
$"{nameof(L5kIngest)}.{nameof(DefaultDeviceHostAddress)} must be set before {nameof(Ingest)} is called — every imported tag needs a target device.");
|
||||
|
||||
// Index UDT definitions by name so we can fan out structure tags inline.
|
||||
var udtIndex = new Dictionary<string, IReadOnlyList<AbCipStructureMember>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var dt in document.DataTypes)
|
||||
{
|
||||
var members = new List<AbCipStructureMember>(dt.Members.Count);
|
||||
foreach (var m in dt.Members)
|
||||
{
|
||||
var atomic = TryMapAtomic(m.DataType);
|
||||
var memberType = atomic ?? AbCipDataType.Structure;
|
||||
var writable = !IsReadOnly(m.ExternalAccess) && !IsAccessNone(m.ExternalAccess);
|
||||
members.Add(new AbCipStructureMember(
|
||||
Name: m.Name,
|
||||
DataType: memberType,
|
||||
Writable: writable,
|
||||
Description: m.Description,
|
||||
AoiQualifier: MapAoiUsage(m.Usage)));
|
||||
}
|
||||
udtIndex[dt.Name] = members;
|
||||
}
|
||||
|
||||
var tags = new List<AbCipTagDefinition>();
|
||||
var skippedAliases = 0;
|
||||
var skippedNoAccess = 0;
|
||||
foreach (var t in document.Tags)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(t.AliasFor)) { skippedAliases++; continue; }
|
||||
if (IsAccessNone(t.ExternalAccess)) { skippedNoAccess++; continue; }
|
||||
|
||||
var atomic = TryMapAtomic(t.DataType);
|
||||
AbCipDataType dataType;
|
||||
IReadOnlyList<AbCipStructureMember>? members = null;
|
||||
if (atomic is { } a)
|
||||
{
|
||||
dataType = a;
|
||||
}
|
||||
else
|
||||
{
|
||||
dataType = AbCipDataType.Structure;
|
||||
if (udtIndex.TryGetValue(t.DataType, out var udtMembers))
|
||||
members = udtMembers;
|
||||
}
|
||||
|
||||
var tagPath = t.ProgramScope is { Length: > 0 }
|
||||
? $"Program:{t.ProgramScope}.{t.Name}"
|
||||
: t.Name;
|
||||
var name = string.IsNullOrEmpty(NamePrefix) ? t.Name : $"{NamePrefix}{t.Name}";
|
||||
// Make the OPC UA tag name unique when both controller-scope + program-scope tags
|
||||
// share the same simple Name.
|
||||
if (t.ProgramScope is { Length: > 0 })
|
||||
name = string.IsNullOrEmpty(NamePrefix)
|
||||
? $"{t.ProgramScope}.{t.Name}"
|
||||
: $"{NamePrefix}{t.ProgramScope}.{t.Name}";
|
||||
|
||||
var writable = !IsReadOnly(t.ExternalAccess);
|
||||
|
||||
tags.Add(new AbCipTagDefinition(
|
||||
Name: name,
|
||||
DeviceHostAddress: DefaultDeviceHostAddress,
|
||||
TagPath: tagPath,
|
||||
DataType: dataType,
|
||||
Writable: writable,
|
||||
Members: members,
|
||||
Description: t.Description));
|
||||
}
|
||||
|
||||
return new L5kIngestResult(tags, skippedAliases, skippedNoAccess);
|
||||
}
|
||||
|
||||
private static bool IsReadOnly(string? externalAccess) =>
|
||||
externalAccess is not null
|
||||
&& externalAccess.Trim().Replace(" ", string.Empty).Equals("ReadOnly", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsAccessNone(string? externalAccess) =>
|
||||
externalAccess is not null && externalAccess.Trim().Equals("None", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-2.6 — map the AOI <c>Usage</c> attribute string to <see cref="AoiQualifier"/>.
|
||||
/// Plain UDT members (Usage = null) + unrecognised values map to <see cref="AoiQualifier.Local"/>.
|
||||
/// </summary>
|
||||
private static AoiQualifier MapAoiUsage(string? usage) =>
|
||||
usage?.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"INPUT" => AoiQualifier.Input,
|
||||
"OUTPUT" => AoiQualifier.Output,
|
||||
"INOUT" => AoiQualifier.InOut,
|
||||
_ => AoiQualifier.Local,
|
||||
};
|
||||
|
||||
/// <summary>Map a Logix atomic type name. Returns <c>null</c> for UDT/structure references.</summary>
|
||||
private static AbCipDataType? TryMapAtomic(string logixType) =>
|
||||
logixType?.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"BOOL" or "BIT" => AbCipDataType.Bool,
|
||||
"SINT" => AbCipDataType.SInt,
|
||||
"INT" => AbCipDataType.Int,
|
||||
"DINT" => AbCipDataType.DInt,
|
||||
"LINT" => AbCipDataType.LInt,
|
||||
"USINT" => AbCipDataType.USInt,
|
||||
"UINT" => AbCipDataType.UInt,
|
||||
"UDINT" => AbCipDataType.UDInt,
|
||||
"ULINT" => AbCipDataType.ULInt,
|
||||
"REAL" => AbCipDataType.Real,
|
||||
"LREAL" => AbCipDataType.LReal,
|
||||
"STRING" => AbCipDataType.String,
|
||||
"DT" or "DATETIME" => AbCipDataType.Dt,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Result of <see cref="L5kIngest.Ingest"/> — produced tags + per-skip-reason counts.</summary>
|
||||
public sealed record L5kIngestResult(
|
||||
IReadOnlyList<AbCipTagDefinition> Tags,
|
||||
int SkippedAliasCount,
|
||||
int SkippedNoAccessCount);
|
||||
469
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
Normal file
469
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5kParser.cs
Normal file
@@ -0,0 +1,469 @@
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Pure-text parser for Studio 5000 L5K controller exports. L5K is a labelled-section export
|
||||
/// with TAG/END_TAG, DATATYPE/END_DATATYPE, PROGRAM/END_PROGRAM blocks. This parser handles
|
||||
/// the common shapes:
|
||||
/// <list type="bullet">
|
||||
/// <item>Controller-scope <c>TAG ... END_TAG</c> with <c>Name</c>, <c>DataType</c>,
|
||||
/// optional <c>ExternalAccess</c>, optional <c>Description</c>.</item>
|
||||
/// <item>Program-scope tags inside <c>PROGRAM ... END_PROGRAM</c>.</item>
|
||||
/// <item>UDT definitions via <c>DATATYPE ... END_DATATYPE</c> with <c>MEMBER</c> lines.</item>
|
||||
/// <item>Alias tags (<c>AliasFor</c>) — recognised + flagged so callers can skip them.</item>
|
||||
/// </list>
|
||||
/// Unknown sections (CONFIG, MODULE, AOI, MOTION_GROUP, etc.) are skipped silently.
|
||||
/// Per Kepware precedent, alias tags are typically skipped on ingest because the alias target
|
||||
/// is what owns the storage — surfacing both creates duplicate writes/reads.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a permissive line-oriented parser, not a full L5K grammar. Comments
|
||||
/// (<c>(* ... *)</c>) are stripped before tokenization. The parser is deliberately tolerant of
|
||||
/// extra whitespace, unknown attributes, and trailing semicolons — real-world L5K files are
|
||||
/// produced by RSLogix exports that vary across versions.
|
||||
/// </remarks>
|
||||
public static class L5kParser
|
||||
{
|
||||
public static L5kDocument Parse(IL5kSource source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
var raw = source.ReadAll();
|
||||
var stripped = StripBlockComments(raw);
|
||||
var lines = stripped.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None);
|
||||
|
||||
var tags = new List<L5kTag>();
|
||||
var datatypes = new List<L5kDataType>();
|
||||
string? currentProgram = null;
|
||||
var i = 0;
|
||||
while (i < lines.Length)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (line.Length == 0) { i++; continue; }
|
||||
|
||||
// PROGRAM block — opens a program scope; the body contains nested TAG blocks.
|
||||
if (StartsWithKeyword(line, "PROGRAM"))
|
||||
{
|
||||
currentProgram = ExtractFirstQuotedOrToken(line.Substring("PROGRAM".Length).Trim());
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (StartsWithKeyword(line, "END_PROGRAM"))
|
||||
{
|
||||
currentProgram = null;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// TAG block — collects 1..N tag entries until END_TAG.
|
||||
if (StartsWithKeyword(line, "TAG"))
|
||||
{
|
||||
var consumed = ParseTagBlock(lines, i, currentProgram, tags);
|
||||
i += consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
// DATATYPE block.
|
||||
if (StartsWithKeyword(line, "DATATYPE"))
|
||||
{
|
||||
var consumed = ParseDataTypeBlock(lines, i, datatypes);
|
||||
i += consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
// PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION block. AOI parameters carry a Usage
|
||||
// attribute (Input / Output / InOut); each PARAMETER becomes a member of the AOI's
|
||||
// L5kDataType entry so AOI-typed tags pick up a layout the same way UDT-typed tags do.
|
||||
if (StartsWithKeyword(line, "ADD_ON_INSTRUCTION_DEFINITION"))
|
||||
{
|
||||
var consumed = ParseAoiDefinitionBlock(lines, i, datatypes);
|
||||
i += consumed;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return new L5kDocument(tags, datatypes);
|
||||
}
|
||||
|
||||
// ---- TAG block ---------------------------------------------------------
|
||||
|
||||
// Each TAG block contains 1..N entries of the form:
|
||||
// TagName : DataType (Description := "...", ExternalAccess := Read/Write) := initialValue;
|
||||
// until END_TAG. Entries can span multiple lines, terminated by ';'.
|
||||
private static int ParseTagBlock(string[] lines, int start, string? program, List<L5kTag> into)
|
||||
{
|
||||
var i = start + 1;
|
||||
while (i < lines.Length)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (StartsWithKeyword(line, "END_TAG")) return i - start + 1;
|
||||
if (line.Length == 0) { i++; continue; }
|
||||
|
||||
var sb = new System.Text.StringBuilder(line);
|
||||
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
|
||||
{
|
||||
var peek = lines[i + 1].Trim();
|
||||
if (StartsWithKeyword(peek, "END_TAG")) break;
|
||||
i++;
|
||||
sb.Append(' ').Append(peek);
|
||||
}
|
||||
i++;
|
||||
|
||||
var entry = sb.ToString().TrimEnd(';').Trim();
|
||||
var tag = ParseTagEntry(entry, program);
|
||||
if (tag is not null) into.Add(tag);
|
||||
}
|
||||
return i - start;
|
||||
}
|
||||
|
||||
private static L5kTag? ParseTagEntry(string entry, string? program)
|
||||
{
|
||||
// entry shape: Name : DataType [ (attribute := value, ...) ] [ := initialValue ]
|
||||
// Find the first ':' that separates Name from DataType. Avoid ':=' (the assign op).
|
||||
var colonIdx = FindBareColon(entry);
|
||||
if (colonIdx < 0) return null;
|
||||
|
||||
var name = entry.Substring(0, colonIdx).Trim();
|
||||
if (name.Length == 0) return null;
|
||||
|
||||
var rest = entry.Substring(colonIdx + 1).Trim();
|
||||
// The attribute parens themselves contain ':=' assignments, so locate the top-level
|
||||
// assignment (depth-0 ':=') that introduces the initial value before stripping.
|
||||
var assignIdx = FindTopLevelAssign(rest);
|
||||
var head = assignIdx >= 0 ? rest.Substring(0, assignIdx).Trim() : rest;
|
||||
|
||||
// Pull attribute tuple out of head: "DataType (attr := val, attr := val)".
|
||||
string dataType;
|
||||
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var openParen = head.IndexOf('(');
|
||||
if (openParen >= 0)
|
||||
{
|
||||
dataType = head.Substring(0, openParen).Trim();
|
||||
var closeParen = head.LastIndexOf(')');
|
||||
if (closeParen > openParen)
|
||||
{
|
||||
var attrBody = head.Substring(openParen + 1, closeParen - openParen - 1);
|
||||
ParseAttributeList(attrBody, attributes);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
dataType = head.Trim();
|
||||
}
|
||||
|
||||
if (dataType.Length == 0) return null;
|
||||
|
||||
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
|
||||
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
|
||||
var aliasFor = attributes.TryGetValue("AliasFor", out var af) ? Unquote(af) : null;
|
||||
|
||||
return new L5kTag(
|
||||
Name: name,
|
||||
DataType: dataType,
|
||||
ProgramScope: program,
|
||||
ExternalAccess: externalAccess,
|
||||
Description: description,
|
||||
AliasFor: aliasFor);
|
||||
}
|
||||
|
||||
// Find the first ':=' at depth 0 (not inside parens / brackets / quotes). Returns -1 if none.
|
||||
private static int FindTopLevelAssign(string entry)
|
||||
{
|
||||
var depth = 0;
|
||||
var inQuote = false;
|
||||
for (var k = 0; k < entry.Length - 1; k++)
|
||||
{
|
||||
var c = entry[k];
|
||||
if (c == '"' || c == '\'') inQuote = !inQuote;
|
||||
if (inQuote) continue;
|
||||
if (c == '(' || c == '[' || c == '{') depth++;
|
||||
else if (c == ')' || c == ']' || c == '}') depth--;
|
||||
else if (c == ':' && entry[k + 1] == '=' && depth == 0) return k;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Find the first colon that is NOT part of ':=' and not inside a quoted string.
|
||||
private static int FindBareColon(string entry)
|
||||
{
|
||||
var inQuote = false;
|
||||
for (var k = 0; k < entry.Length; k++)
|
||||
{
|
||||
var c = entry[k];
|
||||
if (c == '"' || c == '\'') inQuote = !inQuote;
|
||||
if (inQuote) continue;
|
||||
if (c != ':') continue;
|
||||
if (k + 1 < entry.Length && entry[k + 1] == '=') continue;
|
||||
return k;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static void ParseAttributeList(string body, Dictionary<string, string> into)
|
||||
{
|
||||
foreach (var part in SplitTopLevelCommas(body))
|
||||
{
|
||||
var assign = part.IndexOf(":=", StringComparison.Ordinal);
|
||||
if (assign < 0) continue;
|
||||
var key = part.Substring(0, assign).Trim();
|
||||
var val = part.Substring(assign + 2).Trim();
|
||||
if (key.Length > 0) into[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitTopLevelCommas(string body)
|
||||
{
|
||||
var depth = 0;
|
||||
var inQuote = false;
|
||||
var start = 0;
|
||||
for (var k = 0; k < body.Length; k++)
|
||||
{
|
||||
var c = body[k];
|
||||
if (c == '"' || c == '\'') inQuote = !inQuote;
|
||||
if (inQuote) continue;
|
||||
if (c == '(' || c == '[' || c == '{') depth++;
|
||||
else if (c == ')' || c == ']' || c == '}') depth--;
|
||||
else if (c == ',' && depth == 0)
|
||||
{
|
||||
yield return body.Substring(start, k - start);
|
||||
start = k + 1;
|
||||
}
|
||||
}
|
||||
if (start < body.Length) yield return body.Substring(start);
|
||||
}
|
||||
|
||||
// ---- DATATYPE block ----------------------------------------------------
|
||||
|
||||
private static int ParseDataTypeBlock(string[] lines, int start, List<L5kDataType> into)
|
||||
{
|
||||
var first = lines[start].Trim();
|
||||
var head = first.Substring("DATATYPE".Length).Trim();
|
||||
var name = ExtractFirstQuotedOrToken(head);
|
||||
var members = new List<L5kMember>();
|
||||
var i = start + 1;
|
||||
while (i < lines.Length)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (StartsWithKeyword(line, "END_DATATYPE"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||
return i - start + 1;
|
||||
}
|
||||
if (line.Length == 0) { i++; continue; }
|
||||
|
||||
if (StartsWithKeyword(line, "MEMBER"))
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(line);
|
||||
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
|
||||
{
|
||||
var peek = lines[i + 1].Trim();
|
||||
if (StartsWithKeyword(peek, "END_DATATYPE")) break;
|
||||
i++;
|
||||
sb.Append(' ').Append(peek);
|
||||
}
|
||||
var entry = sb.ToString().TrimEnd(';').Trim();
|
||||
entry = entry.Substring("MEMBER".Length).Trim();
|
||||
var member = ParseMemberEntry(entry);
|
||||
if (member is not null) members.Add(member);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||
return i - start;
|
||||
}
|
||||
|
||||
private static L5kMember? ParseMemberEntry(string entry)
|
||||
{
|
||||
// entry shape: MemberName : DataType [ [arrayDim] ] [ (attr := val, ...) ] [ := default ]
|
||||
var colonIdx = FindBareColon(entry);
|
||||
if (colonIdx < 0) return null;
|
||||
var name = entry.Substring(0, colonIdx).Trim();
|
||||
if (name.Length == 0) return null;
|
||||
|
||||
var rest = entry.Substring(colonIdx + 1).Trim();
|
||||
var assignIdx = FindTopLevelAssign(rest);
|
||||
if (assignIdx >= 0) rest = rest.Substring(0, assignIdx).Trim();
|
||||
|
||||
int? arrayDim = null;
|
||||
var bracketOpen = rest.IndexOf('[');
|
||||
if (bracketOpen >= 0)
|
||||
{
|
||||
var bracketClose = rest.IndexOf(']', bracketOpen + 1);
|
||||
if (bracketClose > bracketOpen)
|
||||
{
|
||||
var dimText = rest.Substring(bracketOpen + 1, bracketClose - bracketOpen - 1).Trim();
|
||||
if (int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim))
|
||||
arrayDim = dim;
|
||||
rest = (rest.Substring(0, bracketOpen) + rest.Substring(bracketClose + 1)).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
string typePart;
|
||||
var attributes = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var openParen = rest.IndexOf('(');
|
||||
if (openParen >= 0)
|
||||
{
|
||||
typePart = rest.Substring(0, openParen).Trim();
|
||||
var closeParen = rest.LastIndexOf(')');
|
||||
if (closeParen > openParen)
|
||||
{
|
||||
var attrBody = rest.Substring(openParen + 1, closeParen - openParen - 1);
|
||||
ParseAttributeList(attrBody, attributes);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
typePart = rest.Trim();
|
||||
}
|
||||
|
||||
if (typePart.Length == 0) return null;
|
||||
var externalAccess = attributes.TryGetValue("ExternalAccess", out var ea) ? ea.Trim() : null;
|
||||
var description = attributes.TryGetValue("Description", out var d) ? Unquote(d) : null;
|
||||
// PR abcip-2.6 — Usage attribute on AOI parameters (Input / Output / InOut). Plain UDT
|
||||
// members don't carry it; null on a regular DATATYPE MEMBER is the default + maps to Local
|
||||
// in the ingest layer.
|
||||
var usage = attributes.TryGetValue("Usage", out var u) ? u.Trim() : null;
|
||||
return new L5kMember(name, typePart, arrayDim, externalAccess, description, usage);
|
||||
}
|
||||
|
||||
// ---- AOI block ---------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-2.6 — parse <c>ADD_ON_INSTRUCTION_DEFINITION ... END_ADD_ON_INSTRUCTION_DEFINITION</c>
|
||||
/// blocks. Body is structured around PARAMETER entries (each carrying a <c>Usage</c>
|
||||
/// attribute) and optional LOCAL_TAGS / ROUTINE blocks. We extract the parameters as
|
||||
/// <see cref="L5kMember"/> rows + leave routines alone — only the surface API matters for
|
||||
/// tag-discovery fan-out. The L5K format encloses parameters either inside a
|
||||
/// <c>PARAMETERS ... END_PARAMETERS</c> block or as bare <c>PARAMETER ... ;</c> lines at
|
||||
/// the AOI top level depending on Studio 5000 export options; this parser accepts both.
|
||||
/// </summary>
|
||||
private static int ParseAoiDefinitionBlock(string[] lines, int start, List<L5kDataType> into)
|
||||
{
|
||||
var first = lines[start].Trim();
|
||||
var head = first.Substring("ADD_ON_INSTRUCTION_DEFINITION".Length).Trim();
|
||||
var name = ExtractFirstQuotedOrToken(head);
|
||||
var members = new List<L5kMember>();
|
||||
var i = start + 1;
|
||||
var inLocalsBlock = false;
|
||||
var inRoutineBlock = false;
|
||||
while (i < lines.Length)
|
||||
{
|
||||
var line = lines[i].Trim();
|
||||
if (StartsWithKeyword(line, "END_ADD_ON_INSTRUCTION_DEFINITION"))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||
return i - start + 1;
|
||||
}
|
||||
if (line.Length == 0) { i++; continue; }
|
||||
|
||||
// Skip routine bodies — they hold ladder / ST / FBD code we don't care about for
|
||||
// tag-discovery, and their own END_ROUTINE / END_LOCAL_TAGS tokens close them out.
|
||||
if (StartsWithKeyword(line, "ROUTINE")) { inRoutineBlock = true; i++; continue; }
|
||||
if (StartsWithKeyword(line, "END_ROUTINE")) { inRoutineBlock = false; i++; continue; }
|
||||
if (StartsWithKeyword(line, "LOCAL_TAGS")) { inLocalsBlock = true; i++; continue; }
|
||||
if (StartsWithKeyword(line, "END_LOCAL_TAGS")) { inLocalsBlock = false; i++; continue; }
|
||||
if (inRoutineBlock || inLocalsBlock) { i++; continue; }
|
||||
|
||||
// PARAMETERS / END_PARAMETERS wrappers are skipped — bare PARAMETER lines drive parsing.
|
||||
if (StartsWithKeyword(line, "PARAMETERS")) { i++; continue; }
|
||||
if (StartsWithKeyword(line, "END_PARAMETERS")) { i++; continue; }
|
||||
|
||||
if (StartsWithKeyword(line, "PARAMETER"))
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(line);
|
||||
while (!sb.ToString().TrimEnd().EndsWith(';') && i + 1 < lines.Length)
|
||||
{
|
||||
var peek = lines[i + 1].Trim();
|
||||
if (StartsWithKeyword(peek, "END_ADD_ON_INSTRUCTION_DEFINITION")) break;
|
||||
i++;
|
||||
sb.Append(' ').Append(peek);
|
||||
}
|
||||
var entry = sb.ToString().TrimEnd(';').Trim();
|
||||
entry = entry.Substring("PARAMETER".Length).Trim();
|
||||
var member = ParseMemberEntry(entry);
|
||||
if (member is not null) members.Add(member);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (!string.IsNullOrEmpty(name)) into.Add(new L5kDataType(name, members));
|
||||
return i - start;
|
||||
}
|
||||
|
||||
// ---- helpers -----------------------------------------------------------
|
||||
|
||||
private static bool StartsWithKeyword(string line, string keyword)
|
||||
{
|
||||
if (line.Length < keyword.Length) return false;
|
||||
if (!line.StartsWith(keyword, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (line.Length == keyword.Length) return true;
|
||||
var next = line[keyword.Length];
|
||||
return !char.IsLetterOrDigit(next) && next != '_';
|
||||
}
|
||||
|
||||
private static string ExtractFirstQuotedOrToken(string fragment)
|
||||
{
|
||||
var trimmed = fragment.TrimStart();
|
||||
if (trimmed.Length == 0) return string.Empty;
|
||||
if (trimmed[0] == '"' || trimmed[0] == '\'')
|
||||
{
|
||||
var quote = trimmed[0];
|
||||
var end = trimmed.IndexOf(quote, 1);
|
||||
if (end > 0) return trimmed.Substring(1, end - 1);
|
||||
}
|
||||
var k = 0;
|
||||
while (k < trimmed.Length)
|
||||
{
|
||||
var c = trimmed[k];
|
||||
if (char.IsWhiteSpace(c) || c == '(' || c == ',' || c == ';') break;
|
||||
k++;
|
||||
}
|
||||
return trimmed.Substring(0, k);
|
||||
}
|
||||
|
||||
private static string Unquote(string s)
|
||||
{
|
||||
s = s.Trim();
|
||||
if (s.Length >= 2 && (s[0] == '"' || s[0] == '\'') && s[s.Length - 1] == s[0])
|
||||
return s.Substring(1, s.Length - 2);
|
||||
return s;
|
||||
}
|
||||
|
||||
private static string StripBlockComments(string text)
|
||||
{
|
||||
// L5K comments: `(* ... *)`. Strip so the line scanner doesn't trip on tokens inside.
|
||||
var pattern = new Regex(@"\(\*.*?\*\)", RegexOptions.Singleline);
|
||||
return pattern.Replace(text, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Output of <see cref="L5kParser.Parse(IL5kSource)"/>.</summary>
|
||||
public sealed record L5kDocument(IReadOnlyList<L5kTag> Tags, IReadOnlyList<L5kDataType> DataTypes);
|
||||
|
||||
/// <summary>One L5K tag entry (controller- or program-scope).</summary>
|
||||
public sealed record L5kTag(
|
||||
string Name,
|
||||
string DataType,
|
||||
string? ProgramScope,
|
||||
string? ExternalAccess,
|
||||
string? Description,
|
||||
string? AliasFor);
|
||||
|
||||
/// <summary>One UDT definition extracted from a <c>DATATYPE ... END_DATATYPE</c> block.</summary>
|
||||
public sealed record L5kDataType(string Name, IReadOnlyList<L5kMember> Members);
|
||||
|
||||
/// <summary>One member line inside a UDT definition or AOI parameter list.</summary>
|
||||
/// <remarks>
|
||||
/// PR abcip-2.6 — <see cref="Usage"/> carries the AOI <c>Usage</c> attribute (<c>Input</c> /
|
||||
/// <c>Output</c> / <c>InOut</c>) raw text. Plain UDT members + L5K AOI <c>LOCAL_TAGS</c> leave
|
||||
/// it null; the ingest layer maps null → <see cref="AoiQualifier.Local"/>.
|
||||
/// </remarks>
|
||||
public sealed record L5kMember(
|
||||
string Name,
|
||||
string DataType,
|
||||
int? ArrayDim,
|
||||
string? ExternalAccess,
|
||||
string? Description = null,
|
||||
string? Usage = null);
|
||||
237
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs
Normal file
237
src/ZB.MOM.WW.OtOpcUa.Driver.AbCip/Import/L5xParser.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using System.Globalization;
|
||||
using System.Xml;
|
||||
using System.Xml.XPath;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
/// <summary>
|
||||
/// XML-format parser for Studio 5000 L5X controller exports. L5X is the XML sibling of L5K
|
||||
/// and carries the same tag / datatype / program shape, plus richer metadata (notably the
|
||||
/// AddOnInstructionDefinition catalogue and explicit <c>TagType</c> attributes).
|
||||
/// <para>
|
||||
/// This parser produces the same <see cref="L5kDocument"/> bundle as
|
||||
/// <see cref="L5kParser"/> so <see cref="L5kIngest"/> consumes both formats interchangeably.
|
||||
/// The two parsers share the post-parse downstream layer; the only difference is how the
|
||||
/// bundle is materialized from the source bytes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// AOIs (<c>AddOnInstructionDefinition</c>) are surfaced as L5K-style UDT entries — their
|
||||
/// parameters become <see cref="L5kMember"/> rows so AOI-typed tags pick up a member layout
|
||||
/// the same way UDT-typed tags do. Full Inputs/Outputs/InOut directional metadata + per-call
|
||||
/// parameter scoping is deferred to PR 2.6 per plan; this PR keeps AOIs visible without
|
||||
/// attempting to model their call semantics.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Uses <see cref="System.Xml.XPath"/> with an <see cref="XPathDocument"/> for read-only
|
||||
/// traversal. L5X exports are typically <50 MB, so a single in-memory navigator beats
|
||||
/// forward-only <c>XmlReader</c> on simplicity for the same throughput at this size class.
|
||||
/// The parser is permissive about missing optional attributes — a real export always has
|
||||
/// <c>Name</c> + <c>DataType</c>, but <c>ExternalAccess</c> defaults to <c>Read/Write</c>
|
||||
/// when absent (matching Studio 5000's own default for new tags).
|
||||
/// </remarks>
|
||||
public static class L5xParser
|
||||
{
|
||||
public static L5kDocument Parse(IL5kSource source)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
var xml = source.ReadAll();
|
||||
|
||||
using var reader = XmlReader.Create(
|
||||
new System.IO.StringReader(xml),
|
||||
new XmlReaderSettings
|
||||
{
|
||||
// L5X exports never include a DOCTYPE, but disable DTD processing defensively.
|
||||
DtdProcessing = DtdProcessing.Prohibit,
|
||||
IgnoreWhitespace = true,
|
||||
IgnoreComments = true,
|
||||
});
|
||||
var doc = new XPathDocument(reader);
|
||||
var nav = doc.CreateNavigator();
|
||||
|
||||
var tags = new List<L5kTag>();
|
||||
var datatypes = new List<L5kDataType>();
|
||||
|
||||
// Controller-scope tags: /RSLogix5000Content/Controller/Tags/Tag
|
||||
foreach (XPathNavigator tagNode in nav.Select("/RSLogix5000Content/Controller/Tags/Tag"))
|
||||
{
|
||||
var t = ReadTag(tagNode, programScope: null);
|
||||
if (t is not null) tags.Add(t);
|
||||
}
|
||||
|
||||
// Program-scope tags: /RSLogix5000Content/Controller/Programs/Program/Tags/Tag
|
||||
foreach (XPathNavigator programNode in nav.Select("/RSLogix5000Content/Controller/Programs/Program"))
|
||||
{
|
||||
var programName = programNode.GetAttribute("Name", string.Empty);
|
||||
if (string.IsNullOrEmpty(programName)) continue;
|
||||
foreach (XPathNavigator tagNode in programNode.Select("Tags/Tag"))
|
||||
{
|
||||
var t = ReadTag(tagNode, programName);
|
||||
if (t is not null) tags.Add(t);
|
||||
}
|
||||
}
|
||||
|
||||
// UDTs: /RSLogix5000Content/Controller/DataTypes/DataType
|
||||
foreach (XPathNavigator dtNode in nav.Select("/RSLogix5000Content/Controller/DataTypes/DataType"))
|
||||
{
|
||||
var udt = ReadDataType(dtNode);
|
||||
if (udt is not null) datatypes.Add(udt);
|
||||
}
|
||||
|
||||
// AOIs: surfaced as L5kDataType entries so AOI-typed tags pick up a member layout.
|
||||
// Per the plan, full directional Input/Output/InOut modelling is deferred to PR 2.6.
|
||||
foreach (XPathNavigator aoiNode in nav.Select("/RSLogix5000Content/Controller/AddOnInstructionDefinitions/AddOnInstructionDefinition"))
|
||||
{
|
||||
var aoi = ReadAddOnInstruction(aoiNode);
|
||||
if (aoi is not null) datatypes.Add(aoi);
|
||||
}
|
||||
|
||||
return new L5kDocument(tags, datatypes);
|
||||
}
|
||||
|
||||
private static L5kTag? ReadTag(XPathNavigator tagNode, string? programScope)
|
||||
{
|
||||
var name = tagNode.GetAttribute("Name", string.Empty);
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
|
||||
var tagType = tagNode.GetAttribute("TagType", string.Empty); // Base | Alias | Produced | Consumed
|
||||
var dataType = tagNode.GetAttribute("DataType", string.Empty);
|
||||
var aliasFor = tagNode.GetAttribute("AliasFor", string.Empty);
|
||||
var externalAccess = tagNode.GetAttribute("ExternalAccess", string.Empty);
|
||||
|
||||
// Alias tags often omit DataType (it's inherited from the target). Surface them with
|
||||
// an empty type — L5kIngest skips alias entries before TryMapAtomic ever sees the type.
|
||||
if (string.IsNullOrEmpty(dataType)
|
||||
&& !string.Equals(tagType, "Alias", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Description child — L5X wraps description text in <Description> (sometimes inside CDATA).
|
||||
string? description = null;
|
||||
var descNode = tagNode.SelectSingleNode("Description");
|
||||
if (descNode is not null)
|
||||
{
|
||||
var raw = descNode.Value;
|
||||
if (!string.IsNullOrEmpty(raw)) description = raw.Trim();
|
||||
}
|
||||
|
||||
return new L5kTag(
|
||||
Name: name,
|
||||
DataType: string.IsNullOrEmpty(dataType) ? string.Empty : dataType,
|
||||
ProgramScope: programScope,
|
||||
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
|
||||
Description: description,
|
||||
AliasFor: string.IsNullOrEmpty(aliasFor) ? null : aliasFor);
|
||||
}
|
||||
|
||||
private static L5kDataType? ReadDataType(XPathNavigator dtNode)
|
||||
{
|
||||
var name = dtNode.GetAttribute("Name", string.Empty);
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
|
||||
var members = new List<L5kMember>();
|
||||
foreach (XPathNavigator memberNode in dtNode.Select("Members/Member"))
|
||||
{
|
||||
var m = ReadMember(memberNode);
|
||||
if (m is not null) members.Add(m);
|
||||
}
|
||||
return new L5kDataType(name, members);
|
||||
}
|
||||
|
||||
private static L5kMember? ReadMember(XPathNavigator memberNode)
|
||||
{
|
||||
var name = memberNode.GetAttribute("Name", string.Empty);
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
|
||||
// Skip auto-inserted hidden host members for backing storage of BOOL packing — they're
|
||||
// emitted by RSLogix as members named with the ZZZZZZZZZZ prefix and aren't useful to
|
||||
// surface as OPC UA variables.
|
||||
if (name.StartsWith("ZZZZZZZZZZ", StringComparison.Ordinal)) return null;
|
||||
|
||||
var dataType = memberNode.GetAttribute("DataType", string.Empty);
|
||||
if (string.IsNullOrEmpty(dataType)) return null;
|
||||
|
||||
var externalAccess = memberNode.GetAttribute("ExternalAccess", string.Empty);
|
||||
|
||||
int? arrayDim = null;
|
||||
var dimText = memberNode.GetAttribute("Dimension", string.Empty);
|
||||
if (!string.IsNullOrEmpty(dimText)
|
||||
&& int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim)
|
||||
&& dim > 0)
|
||||
{
|
||||
arrayDim = dim;
|
||||
}
|
||||
|
||||
// Description child — same shape as on Tag nodes; sometimes wrapped in CDATA.
|
||||
string? description = null;
|
||||
var descNode = memberNode.SelectSingleNode("Description");
|
||||
if (descNode is not null)
|
||||
{
|
||||
var raw = descNode.Value;
|
||||
if (!string.IsNullOrEmpty(raw)) description = raw.Trim();
|
||||
}
|
||||
|
||||
return new L5kMember(
|
||||
Name: name,
|
||||
DataType: dataType,
|
||||
ArrayDim: arrayDim,
|
||||
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
|
||||
Description: description);
|
||||
}
|
||||
|
||||
private static L5kDataType? ReadAddOnInstruction(XPathNavigator aoiNode)
|
||||
{
|
||||
var name = aoiNode.GetAttribute("Name", string.Empty);
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
|
||||
var members = new List<L5kMember>();
|
||||
foreach (XPathNavigator paramNode in aoiNode.Select("Parameters/Parameter"))
|
||||
{
|
||||
var paramName = paramNode.GetAttribute("Name", string.Empty);
|
||||
if (string.IsNullOrEmpty(paramName)) continue;
|
||||
|
||||
// RSLogix marks the implicit EnableIn / EnableOut parameters as Hidden=true.
|
||||
// Skip them — they aren't part of the AOI's user-facing surface.
|
||||
var hidden = paramNode.GetAttribute("Hidden", string.Empty);
|
||||
if (string.Equals(hidden, "true", StringComparison.OrdinalIgnoreCase)) continue;
|
||||
|
||||
var dataType = paramNode.GetAttribute("DataType", string.Empty);
|
||||
if (string.IsNullOrEmpty(dataType)) continue;
|
||||
|
||||
var externalAccess = paramNode.GetAttribute("ExternalAccess", string.Empty);
|
||||
|
||||
int? arrayDim = null;
|
||||
var dimText = paramNode.GetAttribute("Dimension", string.Empty);
|
||||
if (!string.IsNullOrEmpty(dimText)
|
||||
&& int.TryParse(dimText, NumberStyles.Integer, CultureInfo.InvariantCulture, out var dim)
|
||||
&& dim > 0)
|
||||
{
|
||||
arrayDim = dim;
|
||||
}
|
||||
|
||||
string? paramDescription = null;
|
||||
var paramDescNode = paramNode.SelectSingleNode("Description");
|
||||
if (paramDescNode is not null)
|
||||
{
|
||||
var raw = paramDescNode.Value;
|
||||
if (!string.IsNullOrEmpty(raw)) paramDescription = raw.Trim();
|
||||
}
|
||||
|
||||
// PR abcip-2.6 — capture the AOI Usage attribute (Input / Output / InOut). RSLogix
|
||||
// also serialises Local AOI tags inside <LocalTags>, but those don't go through this
|
||||
// path — only <Parameters>/<Parameter> entries do — so any Usage value on a parameter
|
||||
// is one of the directional buckets.
|
||||
var usage = paramNode.GetAttribute("Usage", string.Empty);
|
||||
|
||||
members.Add(new L5kMember(
|
||||
Name: paramName,
|
||||
DataType: dataType,
|
||||
ArrayDim: arrayDim,
|
||||
ExternalAccess: string.IsNullOrEmpty(externalAccess) ? null : externalAccess,
|
||||
Description: paramDescription,
|
||||
Usage: string.IsNullOrEmpty(usage) ? null : usage));
|
||||
}
|
||||
return new L5kDataType(name, members);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,17 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
Name = p.TagName,
|
||||
Timeout = p.Timeout,
|
||||
};
|
||||
// PR abcip-1.2 — Logix STRINGnn variant decoding. When the caller pins a non-default
|
||||
// DATA-array capacity (STRING_20 / STRING_40 / STRING_80 etc.), forward it to libplctag
|
||||
// via the StringMaxCapacity attribute so GetString / SetString truncate at the right
|
||||
// boundary. Null leaves libplctag at its default 82-byte STRING for back-compat.
|
||||
if (p.StringMaxCapacity is int cap && cap > 0)
|
||||
_tag.StringMaxCapacity = (uint)cap;
|
||||
// PR abcip-1.3 — slice reads. Setting ElementCount tells libplctag to allocate a buffer
|
||||
// covering N consecutive elements; the array-read planner pairs this with TagName=Tag[N]
|
||||
// to issue one Rockwell array read for a [N..M] slice.
|
||||
if (p.ElementCount is int n && n > 0)
|
||||
_tag.ElementCount = n;
|
||||
}
|
||||
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) => _tag.InitializeAsync(cancellationToken);
|
||||
@@ -50,7 +61,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
AbCipDataType.Real => _tag.GetFloat32(offset),
|
||||
AbCipDataType.LReal => _tag.GetFloat64(offset),
|
||||
AbCipDataType.String => _tag.GetString(offset),
|
||||
AbCipDataType.Dt => _tag.GetInt32(offset),
|
||||
AbCipDataType.Dt => _tag.GetInt64(offset),
|
||||
AbCipDataType.Structure => null,
|
||||
_ => null,
|
||||
};
|
||||
@@ -105,7 +116,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
||||
break;
|
||||
case AbCipDataType.Dt:
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
_tag.SetInt64(0, Convert.ToInt64(value));
|
||||
break;
|
||||
case AbCipDataType.Structure:
|
||||
throw new NotSupportedException("Whole-UDT writes land in PR 6.");
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
@@ -30,35 +32,87 @@ public sealed record AbLegacyAddress(
|
||||
int? FileNumber,
|
||||
int WordNumber,
|
||||
int? BitIndex,
|
||||
string? SubElement)
|
||||
string? SubElement,
|
||||
AbLegacyAddress? IndirectFileSource = null,
|
||||
AbLegacyAddress? IndirectWordSource = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// True when either the file number or the word number is sourced from another PCCC
|
||||
/// address evaluated at runtime (PLC-5 / SLC indirect addressing — <c>N7:[N7:0]</c> or
|
||||
/// <c>N[N7:0]:5</c>). libplctag PCCC does not natively decode bracket-form indirection,
|
||||
/// so the runtime layer must resolve the inner address first and rewrite the tag name
|
||||
/// before issuing the actual read/write. See <see cref="ToLibplctagName"/>.
|
||||
/// </summary>
|
||||
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
|
||||
|
||||
public string ToLibplctagName()
|
||||
{
|
||||
var file = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||
var wordPart = $"{file}:{WordNumber}";
|
||||
// Re-emit using bracket form when indirect. libplctag's PCCC text decoder does not
|
||||
// accept the bracket form directly — callers that need a libplctag-ready name must
|
||||
// resolve the inner addresses first and substitute concrete numbers. Driver runtime
|
||||
// path (TODO: resolve-then-read) is gated on IsIndirect.
|
||||
string filePart;
|
||||
if (IndirectFileSource is not null)
|
||||
{
|
||||
filePart = $"{FileLetter}[{IndirectFileSource.ToLibplctagName()}]";
|
||||
}
|
||||
else
|
||||
{
|
||||
filePart = FileNumber is null ? FileLetter : $"{FileLetter}{FileNumber}";
|
||||
}
|
||||
|
||||
string wordSegment = IndirectWordSource is not null
|
||||
? $"[{IndirectWordSource.ToLibplctagName()}]"
|
||||
: WordNumber.ToString();
|
||||
|
||||
var wordPart = $"{filePart}:{wordSegment}";
|
||||
if (SubElement is not null) wordPart += $".{SubElement}";
|
||||
if (BitIndex is not null) wordPart += $"/{BitIndex}";
|
||||
return wordPart;
|
||||
}
|
||||
|
||||
public static AbLegacyAddress? TryParse(string? value)
|
||||
public static AbLegacyAddress? TryParse(string? value) => TryParse(value, family: null);
|
||||
|
||||
/// <summary>
|
||||
/// Family-aware parser. PLC-5 (RSLogix 5) displays the word + bit indices on
|
||||
/// <c>I:</c>/<c>O:</c> file references as octal — <c>I:001/17</c> is rack 1, bit 15.
|
||||
/// Pass the device's family so the parser can interpret those digits as octal when the
|
||||
/// family's <see cref="AbLegacyPlcFamilyProfile.OctalIoAddressing"/> is true. The parsed
|
||||
/// record stores decimal values; <see cref="ToLibplctagName"/> emits decimal too, which
|
||||
/// is what libplctag's PCCC layer expects.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Also accepts indirect / indexed forms (Issue #247): <c>N7:[N7:0]</c> reads file 7,
|
||||
/// word=value-of(N7:0); <c>N[N7:0]:5</c> reads file=value-of(N7:0), word 5. Recursion
|
||||
/// depth is capped at 1 — the inner address must be a plain direct PCCC address.
|
||||
/// </remarks>
|
||||
public static AbLegacyAddress? TryParse(string? value, AbLegacyPlcFamily? family)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var src = value.Trim();
|
||||
|
||||
// BitIndex: trailing /N
|
||||
int? bitIndex = null;
|
||||
var slashIdx = src.IndexOf('/');
|
||||
if (slashIdx >= 0)
|
||||
var profile = family is null ? null : AbLegacyPlcFamilyProfile.ForFamily(family.Value);
|
||||
|
||||
// BitIndex: trailing /N. Defer numeric parsing until the file letter is known — PLC-5
|
||||
// I:/O: bit indices are octal in RSLogix 5, everything else is decimal.
|
||||
string? bitText = null;
|
||||
var slashIdx = src.LastIndexOf('/');
|
||||
if (slashIdx >= 0 && slashIdx > src.LastIndexOf(']'))
|
||||
{
|
||||
if (!int.TryParse(src[(slashIdx + 1)..], out var bit) || bit < 0 || bit > 31) return null;
|
||||
bitIndex = bit;
|
||||
bitText = src[(slashIdx + 1)..];
|
||||
src = src[..slashIdx];
|
||||
}
|
||||
|
||||
return ParseTail(src, bitText, profile, allowIndirect: true);
|
||||
}
|
||||
|
||||
private static AbLegacyAddress? ParseTail(string src, string? bitText, AbLegacyPlcFamilyProfile? profile, bool allowIndirect)
|
||||
{
|
||||
// SubElement: trailing .NAME (ACC / PRE / EN / DN / TT / CU / CD / FD / etc.)
|
||||
// Only consider dots OUTSIDE of any bracketed inner address — the inner address may
|
||||
// itself contain a sub-element dot (e.g. N[T4:0.ACC]:5).
|
||||
string? subElement = null;
|
||||
var dotIdx = src.LastIndexOf('.');
|
||||
var dotIdx = LastIndexOfTopLevel(src, '.');
|
||||
if (dotIdx >= 0)
|
||||
{
|
||||
var candidate = src[(dotIdx + 1)..];
|
||||
@@ -69,29 +123,149 @@ public sealed record AbLegacyAddress(
|
||||
}
|
||||
}
|
||||
|
||||
var colonIdx = src.IndexOf(':');
|
||||
var colonIdx = IndexOfTopLevel(src, ':');
|
||||
if (colonIdx <= 0) return null;
|
||||
var filePart = src[..colonIdx];
|
||||
var wordPart = src[(colonIdx + 1)..];
|
||||
if (!int.TryParse(wordPart, out var word) || word < 0) return null;
|
||||
|
||||
// File letter + optional file number (single letter for I/O/S, letter+number otherwise).
|
||||
// File letter (always literal) + optional file number — either decimal digits or a
|
||||
// bracketed indirect address like N[N7:0].
|
||||
if (filePart.Length == 0 || !char.IsLetter(filePart[0])) return null;
|
||||
var letterEnd = 1;
|
||||
while (letterEnd < filePart.Length && char.IsLetter(filePart[letterEnd])) letterEnd++;
|
||||
|
||||
var letter = filePart[..letterEnd].ToUpperInvariant();
|
||||
int? fileNumber = null;
|
||||
AbLegacyAddress? indirectFile = null;
|
||||
if (letterEnd < filePart.Length)
|
||||
{
|
||||
if (!int.TryParse(filePart[letterEnd..], out var fn) || fn < 0) return null;
|
||||
fileNumber = fn;
|
||||
var fileTail = filePart[letterEnd..];
|
||||
if (fileTail.Length >= 2 && fileTail[0] == '[' && fileTail[^1] == ']')
|
||||
{
|
||||
if (!allowIndirect) return null;
|
||||
var inner = fileTail[1..^1];
|
||||
indirectFile = ParseInner(inner, profile);
|
||||
if (indirectFile is null) return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!int.TryParse(fileTail, out var fn) || fn < 0) return null;
|
||||
fileNumber = fn;
|
||||
}
|
||||
}
|
||||
|
||||
// Reject unknown file letters — these cover SLC/ML/PLC-5 canonical families.
|
||||
if (!IsKnownFileLetter(letter)) return null;
|
||||
// Function-file letters (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI) are MicroLogix-only.
|
||||
// Structure-file letters (PD/MG/PLS/BT) are gated per family — PD/MG are common on
|
||||
// SLC500 + PLC-5; PLS/BT are PLC-5 only. MicroLogix and LogixPccc reject them.
|
||||
if (!IsKnownFileLetter(letter))
|
||||
{
|
||||
if (IsFunctionFileLetter(letter))
|
||||
{
|
||||
if (profile?.SupportsFunctionFiles != true) return null;
|
||||
}
|
||||
else if (IsStructureFileLetter(letter))
|
||||
{
|
||||
if (!StructureFileSupported(letter, profile)) return null;
|
||||
}
|
||||
else return null;
|
||||
}
|
||||
|
||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement);
|
||||
var octalForIo = profile?.OctalIoAddressing == true && (letter == "I" || letter == "O");
|
||||
|
||||
// Word part: either a numeric literal (octal-aware for PLC-5 I:/O:) or a bracketed
|
||||
// indirect address.
|
||||
int word = 0;
|
||||
AbLegacyAddress? indirectWord = null;
|
||||
if (wordPart.Length >= 2 && wordPart[0] == '[' && wordPart[^1] == ']')
|
||||
{
|
||||
if (!allowIndirect) return null;
|
||||
var inner = wordPart[1..^1];
|
||||
indirectWord = ParseInner(inner, profile);
|
||||
if (indirectWord is null) return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TryParseIndex(wordPart, octalForIo, out word) || word < 0) return null;
|
||||
}
|
||||
|
||||
int? bitIndex = null;
|
||||
if (bitText is not null)
|
||||
{
|
||||
if (!TryParseIndex(bitText, octalForIo, out var bit) || bit < 0 || bit > 31) return null;
|
||||
bitIndex = bit;
|
||||
}
|
||||
|
||||
return new AbLegacyAddress(letter, fileNumber, word, bitIndex, subElement, indirectFile, indirectWord);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse an inner (bracketed) PCCC address with depth-1 cap. The inner address itself
|
||||
/// must NOT be indirect — nesting beyond one level is rejected.
|
||||
/// </summary>
|
||||
private static AbLegacyAddress? ParseInner(string inner, AbLegacyPlcFamilyProfile? profile)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(inner)) return null;
|
||||
var src = inner.Trim();
|
||||
// Reject any further bracket — depth cap at 1.
|
||||
if (src.IndexOf('[') >= 0 || src.IndexOf(']') >= 0) return null;
|
||||
|
||||
string? bitText = null;
|
||||
var slashIdx = src.LastIndexOf('/');
|
||||
if (slashIdx >= 0)
|
||||
{
|
||||
bitText = src[(slashIdx + 1)..];
|
||||
src = src[..slashIdx];
|
||||
}
|
||||
return ParseTail(src, bitText, profile, allowIndirect: false);
|
||||
}
|
||||
|
||||
private static int IndexOfTopLevel(string s, char c)
|
||||
{
|
||||
var depth = 0;
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
{
|
||||
if (s[i] == '[') depth++;
|
||||
else if (s[i] == ']') depth--;
|
||||
else if (depth == 0 && s[i] == c) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static int LastIndexOfTopLevel(string s, char c)
|
||||
{
|
||||
var depth = 0;
|
||||
var last = -1;
|
||||
for (var i = 0; i < s.Length; i++)
|
||||
{
|
||||
if (s[i] == '[') depth++;
|
||||
else if (s[i] == ']') depth--;
|
||||
else if (depth == 0 && s[i] == c) last = i;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
private static bool TryParseIndex(string text, bool octal, out int value)
|
||||
{
|
||||
if (octal)
|
||||
{
|
||||
// Octal accepts only digits 0-7. Reject 8/9 explicitly.
|
||||
if (text.Length == 0) { value = 0; return false; }
|
||||
var start = 0;
|
||||
var sign = 1;
|
||||
if (text[0] == '-') { sign = -1; start = 1; }
|
||||
if (start >= text.Length) { value = 0; return false; }
|
||||
var acc = 0;
|
||||
for (var i = start; i < text.Length; i++)
|
||||
{
|
||||
var c = text[i];
|
||||
if (c < '0' || c > '7') { value = 0; return false; }
|
||||
acc = (acc * 8) + (c - '0');
|
||||
}
|
||||
value = sign * acc;
|
||||
return true;
|
||||
}
|
||||
return int.TryParse(text, out value);
|
||||
}
|
||||
|
||||
private static bool IsKnownFileLetter(string letter) => letter switch
|
||||
@@ -99,4 +273,38 @@ public sealed record AbLegacyAddress(
|
||||
"N" or "F" or "B" or "L" or "ST" or "T" or "C" or "R" or "I" or "O" or "S" or "A" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// MicroLogix 1100/1400 function-file prefixes. Each maps to a single fixed instance with a
|
||||
/// known sub-element catalogue (see <see cref="AbLegacyDataType"/>).
|
||||
/// </summary>
|
||||
internal static bool IsFunctionFileLetter(string letter) => letter switch
|
||||
{
|
||||
"RTC" or "HSC" or "DLS" or "MMI" or "PTO" or "PWM" or "STI" or "EII" or "IOS" or "BHI" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Structure-file prefixes added in #248: PD (PID), MG (Message), PLS (Programmable Limit
|
||||
/// Switch), BT (Block Transfer). Per-family availability is gated by the matching
|
||||
/// <c>Supports*File</c> flag on <see cref="AbLegacyPlcFamilyProfile"/>.
|
||||
/// </summary>
|
||||
internal static bool IsStructureFileLetter(string letter) => letter switch
|
||||
{
|
||||
"PD" or "MG" or "PLS" or "BT" => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static bool StructureFileSupported(string letter, AbLegacyPlcFamilyProfile? profile)
|
||||
{
|
||||
if (profile is null) return false;
|
||||
return letter switch
|
||||
{
|
||||
"PD" => profile.SupportsPidFile,
|
||||
"MG" => profile.SupportsMessageFile,
|
||||
"PLS" => profile.SupportsPlsFile,
|
||||
"BT" => profile.SupportsBlockTransferFile,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,96 @@ public enum AbLegacyDataType
|
||||
CounterElement,
|
||||
/// <summary>Control sub-element — caller addresses <c>.LEN</c>, <c>.POS</c>, <c>.EN</c>, <c>.DN</c>, <c>.ER</c>.</summary>
|
||||
ControlElement,
|
||||
/// <summary>
|
||||
/// MicroLogix 1100/1400 function-file sub-element (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI).
|
||||
/// Sub-element catalogue lives in <see cref="AbLegacyFunctionFile.SubElementType"/>.
|
||||
/// </summary>
|
||||
MicroLogixFunctionFile,
|
||||
/// <summary>
|
||||
/// PD-file (PID) sub-element — caller addresses <c>.SP</c>, <c>.PV</c>, <c>.CV</c>,
|
||||
/// <c>.KP</c>, <c>.KI</c>, <c>.KD</c>, <c>.MAXS</c>, <c>.MINS</c>, <c>.DB</c>, <c>.OUT</c>
|
||||
/// (Float) and <c>.EN</c>, <c>.DN</c>, <c>.MO</c>, <c>.PE</c>, <c>.AUTO</c>, <c>.MAN</c>
|
||||
/// (Boolean status bits in word 0).
|
||||
/// </summary>
|
||||
PidElement,
|
||||
/// <summary>
|
||||
/// MG-file (Message) sub-element — caller addresses <c>.RBE</c>, <c>.MS</c>, <c>.SIZE</c>,
|
||||
/// <c>.LEN</c> (Int32) and <c>.EN</c>, <c>.EW</c>, <c>.ER</c>, <c>.DN</c>, <c>.ST</c>,
|
||||
/// <c>.CO</c>, <c>.NR</c>, <c>.TO</c> (Boolean status bits).
|
||||
/// </summary>
|
||||
MessageElement,
|
||||
/// <summary>
|
||||
/// PLS-file (Programmable Limit Switch) sub-element — caller addresses <c>.LEN</c>
|
||||
/// (Int32). Bit semantics vary by PLC; unknown sub-elements fall back to Int32.
|
||||
/// </summary>
|
||||
PlsElement,
|
||||
/// <summary>
|
||||
/// BT-file (Block Transfer) sub-element — caller addresses <c>.RLEN</c>, <c>.DLEN</c>
|
||||
/// (Int32) and <c>.EN</c>, <c>.ST</c>, <c>.DN</c>, <c>.ER</c>, <c>.CO</c>, <c>.EW</c>,
|
||||
/// <c>.TO</c>, <c>.NR</c> (Boolean status bits in word 0).
|
||||
/// </summary>
|
||||
BlockTransferElement,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MicroLogix function-file sub-element catalogue. Covers the most-commonly-addressed members
|
||||
/// per file — not exhaustive (Rockwell defines 30+ on RTC alone). Unknown sub-elements fall
|
||||
/// back to <see cref="DriverDataType.Int32"/> at the <see cref="AbLegacyDataTypeExtensions"/>
|
||||
/// boundary so the driver never refuses a tag the customer happens to know about.
|
||||
/// </summary>
|
||||
public static class AbLegacyFunctionFile
|
||||
{
|
||||
/// <summary>
|
||||
/// Driver-surface type for <paramref name="fileLetter"/>.<paramref name="subElement"/>.
|
||||
/// Returns <see cref="DriverDataType.Int32"/> if the sub-element is unrecognised — keeps
|
||||
/// the driver permissive without forcing every quirk into the catalogue.
|
||||
/// </summary>
|
||||
public static DriverDataType SubElementType(string fileLetter, string? subElement)
|
||||
{
|
||||
if (subElement is null) return DriverDataType.Int32;
|
||||
var key = (fileLetter.ToUpperInvariant(), subElement.ToUpperInvariant());
|
||||
return key switch
|
||||
{
|
||||
// Real-time clock — all stored as Int16 (year is 4-digit Int16).
|
||||
("RTC", "HR") or ("RTC", "MIN") or ("RTC", "SEC") or
|
||||
("RTC", "MON") or ("RTC", "DAY") or ("RTC", "YR") or ("RTC", "DOW") => DriverDataType.Int32,
|
||||
("RTC", "DS") or ("RTC", "BL") or ("RTC", "EN") => DriverDataType.Boolean,
|
||||
|
||||
// High-speed counter — accumulator/preset are Int32, status flags are bits.
|
||||
("HSC", "ACC") or ("HSC", "PRE") or ("HSC", "OVF") or ("HSC", "UNF") => DriverDataType.Int32,
|
||||
("HSC", "EN") or ("HSC", "UF") or ("HSC", "IF") or
|
||||
("HSC", "IN") or ("HSC", "IH") or ("HSC", "IL") or
|
||||
("HSC", "DN") or ("HSC", "CD") or ("HSC", "CU") => DriverDataType.Boolean,
|
||||
|
||||
// Daylight saving + memory module info.
|
||||
("DLS", "STR") or ("DLS", "STD") => DriverDataType.Int32,
|
||||
("DLS", "EN") => DriverDataType.Boolean,
|
||||
("MMI", "FT") or ("MMI", "LBN") => DriverDataType.Int32,
|
||||
("MMI", "MP") or ("MMI", "MCP") => DriverDataType.Boolean,
|
||||
|
||||
// Pulse-train / PWM output blocks.
|
||||
("PTO", "ACC") or ("PTO", "OF") or ("PTO", "IDA") or ("PTO", "ODA") => DriverDataType.Int32,
|
||||
("PTO", "EN") or ("PTO", "DN") or ("PTO", "EH") or ("PTO", "ED") or
|
||||
("PTO", "RP") or ("PTO", "OUT") => DriverDataType.Boolean,
|
||||
("PWM", "ACC") or ("PWM", "OF") or ("PWM", "PE") or ("PWM", "PD") => DriverDataType.Int32,
|
||||
("PWM", "EN") or ("PWM", "DN") or ("PWM", "EH") or ("PWM", "ED") or
|
||||
("PWM", "RP") or ("PWM", "OUT") => DriverDataType.Boolean,
|
||||
|
||||
// Selectable timed interrupt + event input interrupt.
|
||||
("STI", "SPM") or ("STI", "ER") or ("STI", "PFN") => DriverDataType.Int32,
|
||||
("STI", "EN") or ("STI", "TIE") or ("STI", "DN") or
|
||||
("STI", "PS") or ("STI", "ED") => DriverDataType.Boolean,
|
||||
("EII", "PFN") or ("EII", "ER") => DriverDataType.Int32,
|
||||
("EII", "EN") or ("EII", "TIE") or ("EII", "PE") or
|
||||
("EII", "ES") or ("EII", "ED") => DriverDataType.Boolean,
|
||||
|
||||
// I/O status + base hardware info — mostly status flags + a few counters.
|
||||
("IOS", "ID") or ("IOS", "TYP") => DriverDataType.Int32,
|
||||
("BHI", "OS") or ("BHI", "FRN") or ("BHI", "BSN") or ("BHI", "CC") => DriverDataType.Int32,
|
||||
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Map a PCCC data type to the driver-surface <see cref="DriverDataType"/>.</summary>
|
||||
@@ -40,6 +130,196 @@ public static class AbLegacyDataTypeExtensions
|
||||
AbLegacyDataType.String => DriverDataType.String,
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => DriverDataType.Int32,
|
||||
AbLegacyDataType.MicroLogixFunctionFile => DriverDataType.Int32,
|
||||
// PD/MG/PLS/BT default to Int32 at the parent-element level. The sub-element-aware
|
||||
// EffectiveDriverDataType refines specific members (Float for PID gains, Boolean for
|
||||
// status bits).
|
||||
AbLegacyDataType.PidElement or AbLegacyDataType.MessageElement
|
||||
or AbLegacyDataType.PlsElement or AbLegacyDataType.BlockTransferElement
|
||||
=> DriverDataType.Int32,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Sub-element-aware driver type. Timer/Counter/Control elements expose Boolean status
|
||||
/// bits (<c>.DN</c>, <c>.EN</c>, <c>.TT</c>, <c>.CU</c>, <c>.CD</c>, <c>.OV</c>,
|
||||
/// <c>.UN</c>, <c>.ER</c>, etc.) and Int32 word members (<c>.PRE</c>, <c>.ACC</c>,
|
||||
/// <c>.LEN</c>, <c>.POS</c>). Unknown sub-elements fall back to
|
||||
/// <see cref="ToDriverDataType"/> so the driver remains permissive.
|
||||
/// </summary>
|
||||
public static DriverDataType EffectiveDriverDataType(AbLegacyDataType t, string? subElement)
|
||||
{
|
||||
if (subElement is null) return t.ToDriverDataType();
|
||||
var key = subElement.ToUpperInvariant();
|
||||
return t switch
|
||||
{
|
||||
AbLegacyDataType.TimerElement => key switch
|
||||
{
|
||||
"EN" or "TT" or "DN" => DriverDataType.Boolean,
|
||||
"PRE" or "ACC" => DriverDataType.Int32,
|
||||
_ => t.ToDriverDataType(),
|
||||
},
|
||||
AbLegacyDataType.CounterElement => key switch
|
||||
{
|
||||
"CU" or "CD" or "DN" or "OV" or "UN" => DriverDataType.Boolean,
|
||||
"PRE" or "ACC" => DriverDataType.Int32,
|
||||
_ => t.ToDriverDataType(),
|
||||
},
|
||||
AbLegacyDataType.ControlElement => key switch
|
||||
{
|
||||
"EN" or "EU" or "DN" or "EM" or "ER" or "UL" or "IN" or "FD" => DriverDataType.Boolean,
|
||||
"LEN" or "POS" => DriverDataType.Int32,
|
||||
_ => t.ToDriverDataType(),
|
||||
},
|
||||
// PD-file (PID): SP/PV/CV/KP/KI/KD/MAXS/MINS/DB/OUT are 32-bit floats; EN/DN/MO/PE/
|
||||
// AUTO/MAN/SP_VAL/SP_LL/SP_HL are status bits in word 0.
|
||||
AbLegacyDataType.PidElement => key switch
|
||||
{
|
||||
"SP" or "PV" or "CV" or "KP" or "KI" or "KD"
|
||||
or "MAXS" or "MINS" or "DB" or "OUT" => DriverDataType.Float32,
|
||||
"EN" or "DN" or "MO" or "PE"
|
||||
or "AUTO" or "MAN" or "SP_VAL" or "SP_LL" or "SP_HL" => DriverDataType.Boolean,
|
||||
_ => t.ToDriverDataType(),
|
||||
},
|
||||
// MG-file (Message): RBE/MS/SIZE/LEN are control words; EN/EW/ER/DN/ST/CO/NR/TO are
|
||||
// status bits.
|
||||
AbLegacyDataType.MessageElement => key switch
|
||||
{
|
||||
"RBE" or "MS" or "SIZE" or "LEN" => DriverDataType.Int32,
|
||||
"EN" or "EW" or "ER" or "DN" or "ST" or "CO" or "NR" or "TO" => DriverDataType.Boolean,
|
||||
_ => t.ToDriverDataType(),
|
||||
},
|
||||
// PLS-file (Programmable Limit Switch): LEN is a length word; bit semantics vary by
|
||||
// PLC so unknown sub-elements stay Int32.
|
||||
AbLegacyDataType.PlsElement => key switch
|
||||
{
|
||||
"LEN" => DriverDataType.Int32,
|
||||
_ => t.ToDriverDataType(),
|
||||
},
|
||||
// BT-file (Block Transfer, PLC-5): RLEN/DLEN are length words; EN/ST/DN/ER/CO/EW/
|
||||
// TO/NR are status bits in word 0.
|
||||
AbLegacyDataType.BlockTransferElement => key switch
|
||||
{
|
||||
"RLEN" or "DLEN" => DriverDataType.Int32,
|
||||
"EN" or "ST" or "DN" or "ER" or "CO" or "EW" or "TO" or "NR" => DriverDataType.Boolean,
|
||||
_ => t.ToDriverDataType(),
|
||||
},
|
||||
_ => t.ToDriverDataType(),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bit position within the parent control word for Timer/Counter/Control status bits.
|
||||
/// Returns <c>null</c> if the sub-element is not a known bit member of the given element
|
||||
/// type. Bit numbering follows Rockwell DTAM / PCCC documentation.
|
||||
/// </summary>
|
||||
public static int? StatusBitIndex(AbLegacyDataType t, string? subElement)
|
||||
{
|
||||
if (subElement is null) return null;
|
||||
var key = subElement.ToUpperInvariant();
|
||||
return t switch
|
||||
{
|
||||
// T4 element word 0: bit 13=DN, 14=TT, 15=EN.
|
||||
AbLegacyDataType.TimerElement => key switch
|
||||
{
|
||||
"DN" => 13,
|
||||
"TT" => 14,
|
||||
"EN" => 15,
|
||||
_ => null,
|
||||
},
|
||||
// C5 element word 0: bit 10=UN, 11=OV, 12=DN, 13=CD, 14=CU.
|
||||
AbLegacyDataType.CounterElement => key switch
|
||||
{
|
||||
"UN" => 10,
|
||||
"OV" => 11,
|
||||
"DN" => 12,
|
||||
"CD" => 13,
|
||||
"CU" => 14,
|
||||
_ => null,
|
||||
},
|
||||
// R6 element word 0: bit 8=FD, 9=IN, 10=UL, 11=ER, 12=EM, 13=DN, 14=EU, 15=EN.
|
||||
AbLegacyDataType.ControlElement => key switch
|
||||
{
|
||||
"FD" => 8,
|
||||
"IN" => 9,
|
||||
"UL" => 10,
|
||||
"ER" => 11,
|
||||
"EM" => 12,
|
||||
"DN" => 13,
|
||||
"EU" => 14,
|
||||
"EN" => 15,
|
||||
_ => null,
|
||||
},
|
||||
// PD element word 0 (SLC 5/02+ PID, 1747-RM001 / PLC-5 PID-RM): bit 0=EN, 1=PE,
|
||||
// 2=DN, 3=MO (manual mode), 4=AUTO, 5=MAN, 6=SP_VAL, 7=SP_LL, 8=SP_HL. Bits 4–8 are
|
||||
// the SP-validity / SP-limit flags exposed in RSLogix 5 / 500.
|
||||
AbLegacyDataType.PidElement => key switch
|
||||
{
|
||||
"EN" => 0,
|
||||
"PE" => 1,
|
||||
"DN" => 2,
|
||||
"MO" => 3,
|
||||
"AUTO" => 4,
|
||||
"MAN" => 5,
|
||||
"SP_VAL" => 6,
|
||||
"SP_LL" => 7,
|
||||
"SP_HL" => 8,
|
||||
_ => null,
|
||||
},
|
||||
// MG element word 0 (PLC-5 MSG / SLC 5/05 MSG, 1785-6.5.12 / 1747-RM001):
|
||||
// bit 15=EN, 14=ST, 13=DN, 12=ER, 11=CO, 10=EW, 9=NR, 8=TO.
|
||||
AbLegacyDataType.MessageElement => key switch
|
||||
{
|
||||
"TO" => 8,
|
||||
"NR" => 9,
|
||||
"EW" => 10,
|
||||
"CO" => 11,
|
||||
"ER" => 12,
|
||||
"DN" => 13,
|
||||
"ST" => 14,
|
||||
"EN" => 15,
|
||||
_ => null,
|
||||
},
|
||||
// BT element word 0 (PLC-5 chassis BTR/BTW, 1785-6.5.12):
|
||||
// bit 15=EN, 14=ST, 13=DN, 12=ER, 11=CO, 10=EW, 9=NR, 8=TO. Same layout as MG.
|
||||
AbLegacyDataType.BlockTransferElement => key switch
|
||||
{
|
||||
"TO" => 8,
|
||||
"NR" => 9,
|
||||
"EW" => 10,
|
||||
"CO" => 11,
|
||||
"ER" => 12,
|
||||
"DN" => 13,
|
||||
"ST" => 14,
|
||||
"EN" => 15,
|
||||
_ => null,
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PLC-set status bits — read-only from the OPC UA side. Operator-controllable bits
|
||||
/// (e.g. <c>.EN</c> on a timer/counter, <c>.CU</c>/<c>.CD</c> rung-driven inputs) are
|
||||
/// omitted so they keep default writable behaviour.
|
||||
/// </summary>
|
||||
public static bool IsPlcSetStatusBit(AbLegacyDataType t, string? subElement)
|
||||
{
|
||||
if (subElement is null) return false;
|
||||
var key = subElement.ToUpperInvariant();
|
||||
return t switch
|
||||
{
|
||||
AbLegacyDataType.TimerElement => key is "DN" or "TT",
|
||||
AbLegacyDataType.CounterElement => key is "DN" or "OV" or "UN",
|
||||
AbLegacyDataType.ControlElement => key is "DN" or "EM" or "ER" or "FD" or "UL" or "IN",
|
||||
// PID: PE (PID-error), DN (process-done), SP_VAL/SP_LL/SP_HL are PLC-set status.
|
||||
// EN/MO/AUTO/MAN are operator-controllable via the .EN bit / mode select.
|
||||
AbLegacyDataType.PidElement => key is "PE" or "DN" or "SP_VAL" or "SP_LL" or "SP_HL",
|
||||
// MG/BT: ST (started), DN (done), ER (error), CO (continuous), EW (enabled-waiting),
|
||||
// NR (no-response), TO (timeout) are PLC-set. EN is operator-driven via the rung.
|
||||
AbLegacyDataType.MessageElement => key is "ST" or "DN" or "ER" or "CO" or "EW" or "NR" or "TO",
|
||||
AbLegacyDataType.BlockTransferElement => key is "ST" or "DN" or "ER" or "CO" or "EW" or "NR" or "TO",
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,8 +140,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
continue;
|
||||
}
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
var value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
// Timer/Counter/Control status bits route through GetBit at the parent-word
|
||||
// address — translate the .DN/.EN/etc. sub-element to its standard bit position
|
||||
// and pass it down to the runtime as a synthetic bitIndex.
|
||||
var decodeBit = parsed?.BitIndex
|
||||
?? AbLegacyDataTypeExtensions.StatusBitIndex(def.DataType, parsed?.SubElement);
|
||||
var value = runtime.DecodeValue(def.DataType, decodeBit);
|
||||
results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
@@ -186,7 +191,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
try
|
||||
{
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address);
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
|
||||
|
||||
// Timer/Counter/Control PLC-set status bits (DN, TT, OV, UN, FD, ER, EM, UL,
|
||||
// IN) are read-only — the PLC sets them; any client write would be silently
|
||||
// overwritten on the next scan. Reject up front with BadNotWritable.
|
||||
if (AbLegacyDataTypeExtensions.IsPlcSetStatusBit(def.DataType, parsed?.SubElement))
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
|
||||
// PCCC bit-within-word writes — task #181 pass 2. RMW against a parallel
|
||||
// parent-word runtime (strip the /N bit suffix). Per-parent-word lock serialises
|
||||
@@ -223,6 +237,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (ArgumentOutOfRangeException)
|
||||
{
|
||||
// ST-file string writes exceeding the 82-byte fixed element. Surfaces from
|
||||
// LibplctagLegacyTagRuntime.EncodeValue's length guard; mapped to BadOutOfRange so
|
||||
// the OPC UA client sees a clean rejection rather than a silent truncation.
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
|
||||
@@ -247,12 +268,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
{
|
||||
var parsed = AbLegacyAddress.TryParse(tag.Address, device.PlcFamily);
|
||||
// Timer/Counter/Control sub-elements (.DN/.EN/.TT/.PRE/.ACC/etc.) refine the
|
||||
// base element's Int32 to Boolean for status bits and Int32 for word members.
|
||||
var effectiveType = AbLegacyDataTypeExtensions.EffectiveDriverDataType(
|
||||
tag.DataType, parsed?.SubElement);
|
||||
var plcSetBit = AbLegacyDataTypeExtensions.IsPlcSetStatusBit(
|
||||
tag.DataType, parsed?.SubElement);
|
||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
DriverDataType: effectiveType,
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: tag.Writable
|
||||
SecurityClass: tag.Writable && !plcSetBit
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
@@ -413,10 +441,19 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
{
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
|
||||
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address)
|
||||
var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
|
||||
?? throw new InvalidOperationException(
|
||||
$"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}'.");
|
||||
|
||||
// TODO(#247): libplctag's PCCC text decoder does not natively accept the bracket-form
|
||||
// indirect address. Resolving N7:[N7:0] requires reading the inner address first, then
|
||||
// rewriting the tag name with the resolved word number, then issuing the actual read.
|
||||
// For now we surface a clear runtime error rather than letting libplctag fail with an
|
||||
// opaque parser error.
|
||||
if (parsed.IsIndirect)
|
||||
throw new NotSupportedException(
|
||||
$"AbLegacy tag '{def.Name}' uses indirect addressing ('{def.Address}'); runtime resolution is not yet implemented.");
|
||||
|
||||
var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed record AbLegacyDeviceOptions(
|
||||
|
||||
/// <summary>
|
||||
/// One PCCC-backed OPC UA variable. <paramref name="Address"/> is the canonical PCCC
|
||||
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse"/>.
|
||||
/// file-address string that parses via <see cref="AbLegacyAddress.TryParse(string?)"/>.
|
||||
/// </summary>
|
||||
public sealed record AbLegacyTagDefinition(
|
||||
string Name,
|
||||
|
||||
@@ -12,6 +12,15 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum payload length for an ST (string) file element on SLC / MicroLogix / PLC-5.
|
||||
/// The on-wire layout is a 1-word length prefix followed by 82 ASCII bytes — libplctag's
|
||||
/// <c>SetString</c> handles the framing internally, but it does NOT validate length, so a
|
||||
/// 93-byte source string would silently truncate. We reject up-front so the OPC UA client
|
||||
/// gets a clean <c>BadOutOfRange</c> rather than a corrupted PLC value.
|
||||
/// </summary>
|
||||
internal const int StFileMaxStringLength = 82;
|
||||
|
||||
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
||||
{
|
||||
_tag = new Tag
|
||||
@@ -40,8 +49,25 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
AbLegacyDataType.Long => _tag.GetInt32(0),
|
||||
AbLegacyDataType.Float => _tag.GetFloat32(0),
|
||||
AbLegacyDataType.String => _tag.GetString(0),
|
||||
// Timer/Counter/Control sub-elements: bitIndex is the status bit position within the
|
||||
// parent control word (encoded by AbLegacyDriver from the .DN / .EN / etc. sub-element
|
||||
// name). Word members (.PRE / .ACC / .LEN / .POS) come through with bitIndex=null and
|
||||
// decode as Int32 like before.
|
||||
AbLegacyDataType.TimerElement or AbLegacyDataType.CounterElement
|
||||
or AbLegacyDataType.ControlElement => _tag.GetInt32(0),
|
||||
or AbLegacyDataType.ControlElement => bitIndex is int statusBit
|
||||
? _tag.GetBit(statusBit)
|
||||
: _tag.GetInt32(0),
|
||||
// PD-file (PID): non-bit members (SP/PV/CV/KP/KI/KD/MAXS/MINS/DB/OUT) are 32-bit floats.
|
||||
// Status bits (EN/DN/MO/PE/AUTO/MAN/SP_VAL/SP_LL/SP_HL) live in the parent control word
|
||||
// and read through GetBit — the driver encodes the position via StatusBitIndex.
|
||||
AbLegacyDataType.PidElement => bitIndex is int pidBit
|
||||
? _tag.GetBit(pidBit)
|
||||
: _tag.GetFloat32(0),
|
||||
// MG/BT/PLS: non-bit members (RBE/MS/SIZE/LEN, RLEN/DLEN) are word-sized integers.
|
||||
AbLegacyDataType.MessageElement or AbLegacyDataType.BlockTransferElement
|
||||
or AbLegacyDataType.PlsElement => bitIndex is int statusBit2
|
||||
? _tag.GetBit(statusBit2)
|
||||
: _tag.GetInt32(0),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -70,13 +96,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
_tag.SetFloat32(0, Convert.ToSingle(value));
|
||||
break;
|
||||
case AbLegacyDataType.String:
|
||||
_tag.SetString(0, Convert.ToString(value) ?? string.Empty);
|
||||
{
|
||||
var s = Convert.ToString(value) ?? string.Empty;
|
||||
if (s.Length > StFileMaxStringLength)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(value),
|
||||
$"ST string write exceeds {StFileMaxStringLength}-byte file element capacity (was {s.Length}).");
|
||||
_tag.SetString(0, s);
|
||||
}
|
||||
break;
|
||||
case AbLegacyDataType.TimerElement:
|
||||
case AbLegacyDataType.CounterElement:
|
||||
case AbLegacyDataType.ControlElement:
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
break;
|
||||
// PD-file non-bit writes route to the Float backing store. Status-bit writes within
|
||||
// the parent word are blocked at the driver layer (PLC-set bits are read-only and
|
||||
// operator-controllable bits go through the bit-RMW path with the parent word typed
|
||||
// as Int).
|
||||
case AbLegacyDataType.PidElement:
|
||||
_tag.SetFloat32(0, Convert.ToSingle(value));
|
||||
break;
|
||||
case AbLegacyDataType.MessageElement:
|
||||
case AbLegacyDataType.BlockTransferElement:
|
||||
case AbLegacyDataType.PlsElement:
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
break;
|
||||
default:
|
||||
throw new NotSupportedException($"AbLegacyDataType {type} not writable.");
|
||||
}
|
||||
|
||||
@@ -9,7 +9,13 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
string DefaultCipPath,
|
||||
int MaxTagBytes,
|
||||
bool SupportsStringFile,
|
||||
bool SupportsLongFile)
|
||||
bool SupportsLongFile,
|
||||
bool OctalIoAddressing,
|
||||
bool SupportsFunctionFiles,
|
||||
bool SupportsPidFile,
|
||||
bool SupportsMessageFile,
|
||||
bool SupportsPlsFile,
|
||||
bool SupportsBlockTransferFile)
|
||||
{
|
||||
public static AbLegacyPlcFamilyProfile ForFamily(AbLegacyPlcFamily family) => family switch
|
||||
{
|
||||
@@ -25,21 +31,39 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240, // SLC 5/05 PCCC max packet data
|
||||
SupportsStringFile: true, // ST file available SLC 5/04+
|
||||
SupportsLongFile: true); // L file available SLC 5/05+
|
||||
SupportsLongFile: true, // L file available SLC 5/05+
|
||||
OctalIoAddressing: false, // SLC500 I:/O: indices are decimal in RSLogix 500
|
||||
SupportsFunctionFiles: false, // SLC500 has no function files
|
||||
SupportsPidFile: true, // SLC 5/02+ supports PD via PID instruction
|
||||
SupportsMessageFile: true, // SLC 5/02+ supports MG via MSG instruction
|
||||
SupportsPlsFile: false, // SLC500 has no native PLS file (uses SQO/SQC instead)
|
||||
SupportsBlockTransferFile: false); // SLC500 has no BT file (BT is PLC-5 ChassisIO only)
|
||||
|
||||
public static readonly AbLegacyPlcFamilyProfile MicroLogix = new(
|
||||
LibplctagPlcAttribute: "micrologix",
|
||||
DefaultCipPath: "", // MicroLogix 1100/1400 use direct EIP, no backplane path
|
||||
MaxTagBytes: 232,
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: false); // ML 1100/1200/1400 don't ship L files
|
||||
SupportsLongFile: false, // ML 1100/1200/1400 don't ship L files
|
||||
OctalIoAddressing: false, // MicroLogix follows SLC-style decimal I/O addressing
|
||||
SupportsFunctionFiles: true, // ML 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI
|
||||
SupportsPidFile: false, // MicroLogix 1100/1400 use PID-instruction-only addressing — no PD file type
|
||||
SupportsMessageFile: false, // No MG file — MSG instruction control words live in standard files
|
||||
SupportsPlsFile: false,
|
||||
SupportsBlockTransferFile: false);
|
||||
|
||||
public static readonly AbLegacyPlcFamilyProfile Plc5 = new(
|
||||
LibplctagPlcAttribute: "plc5",
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: false); // PLC-5 predates L files
|
||||
SupportsLongFile: false, // PLC-5 predates L files
|
||||
OctalIoAddressing: true, // RSLogix 5 displays I:/O: word + bit indices as octal
|
||||
SupportsFunctionFiles: false,
|
||||
SupportsPidFile: true, // PLC-5 PID instruction needs PD file
|
||||
SupportsMessageFile: true, // PLC-5 MSG instruction needs MG file
|
||||
SupportsPlsFile: true, // PLC-5 has PLS (programmable limit switch) file
|
||||
SupportsBlockTransferFile: true); // PLC-5 chassis I/O block transfer (BTR/BTW) needs BT file
|
||||
|
||||
/// <summary>
|
||||
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
||||
@@ -51,7 +75,15 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
DefaultCipPath: "1,0",
|
||||
MaxTagBytes: 240,
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: true);
|
||||
SupportsLongFile: true,
|
||||
OctalIoAddressing: false, // Logix natively uses decimal arrays even via the PCCC bridge
|
||||
SupportsFunctionFiles: false,
|
||||
// Logix native UDTs (PID_ENHANCED / MESSAGE) replace the legacy PD/MG file types — the
|
||||
// PCCC bridge does not expose them as letter-prefixed files.
|
||||
SupportsPidFile: false,
|
||||
SupportsMessageFile: false,
|
||||
SupportsPlsFile: false,
|
||||
SupportsBlockTransferFile: false);
|
||||
}
|
||||
|
||||
/// <summary>Which PCCC PLC family the device is.</summary>
|
||||
|
||||
@@ -1,35 +1,57 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Parsed FOCAS address covering the three addressing spaces a driver touches:
|
||||
/// Parsed FOCAS address covering the four addressing spaces a driver touches:
|
||||
/// <see cref="FocasAreaKind.Pmc"/> (letter + byte + optional bit — <c>X0.0</c>, <c>R100</c>,
|
||||
/// <c>F20.3</c>), <see cref="FocasAreaKind.Parameter"/> (CNC parameter number —
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), and <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>).
|
||||
/// <c>PARAM:1020</c>, <c>PARAM:1815/0</c> for bit 0), <see cref="FocasAreaKind.Macro"/>
|
||||
/// (macro variable number — <c>MACRO:100</c>, <c>MACRO:500</c>), and
|
||||
/// <see cref="FocasAreaKind.Diagnostic"/> (CNC diagnostic number, optionally per-axis —
|
||||
/// <c>DIAG:1031</c>, <c>DIAG:280/2</c>) routed through <c>cnc_rddiag</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||
/// relay), <c>D</c> (data table), <c>C</c> (counter), <c>K</c> (keep relay), <c>A</c>
|
||||
/// (message display), <c>E</c> (extended relay), <c>T</c> (timer). Byte numbering is 0-based;
|
||||
/// bit index when present is 0–7 and uses <c>.N</c> for PMC or <c>/N</c> for parameters.
|
||||
/// Diagnostic addresses reuse the <c>/N</c> form to encode an axis index — <c>BitIndex</c>
|
||||
/// carries the 1-based axis number (0 = whole-CNC diagnostic).
|
||||
/// <para>
|
||||
/// Multi-path / multi-channel CNCs (e.g. lathe + sub-spindle, dual-turret) expose multiple
|
||||
/// "paths"; <see cref="PathId"/> selects which one a given address is read from. Encoded
|
||||
/// as a trailing <c>@N</c> after the address body but before any bit / axis suffix —
|
||||
/// <c>R100@2</c>, <c>PARAM:1815@2</c>, <c>PARAM:1815@2/0</c>, <c>MACRO:500@3</c>,
|
||||
/// <c>DIAG:280@2/1</c>. Defaults to <c>1</c> for back-compat (single-path CNCs).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record FocasAddress(
|
||||
FocasAreaKind Kind,
|
||||
string? PmcLetter,
|
||||
int Number,
|
||||
int? BitIndex)
|
||||
int? BitIndex,
|
||||
int PathId = 1)
|
||||
{
|
||||
public string Canonical => Kind switch
|
||||
public string Canonical
|
||||
{
|
||||
FocasAreaKind.Pmc => BitIndex is null
|
||||
? $"{PmcLetter}{Number}"
|
||||
: $"{PmcLetter}{Number}.{BitIndex}",
|
||||
FocasAreaKind.Parameter => BitIndex is null
|
||||
? $"PARAM:{Number}"
|
||||
: $"PARAM:{Number}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}",
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
get
|
||||
{
|
||||
var pathSuffix = PathId == 1 ? string.Empty : $"@{PathId}";
|
||||
return Kind switch
|
||||
{
|
||||
FocasAreaKind.Pmc => BitIndex is null
|
||||
? $"{PmcLetter}{Number}{pathSuffix}"
|
||||
: $"{PmcLetter}{Number}{pathSuffix}.{BitIndex}",
|
||||
FocasAreaKind.Parameter => BitIndex is null
|
||||
? $"PARAM:{Number}{pathSuffix}"
|
||||
: $"PARAM:{Number}{pathSuffix}/{BitIndex}",
|
||||
FocasAreaKind.Macro => $"MACRO:{Number}{pathSuffix}",
|
||||
FocasAreaKind.Diagnostic => BitIndex is null or 0
|
||||
? $"DIAG:{Number}{pathSuffix}"
|
||||
: $"DIAG:{Number}{pathSuffix}/{BitIndex}",
|
||||
_ => $"?{Number}",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static FocasAddress? TryParse(string? value)
|
||||
{
|
||||
@@ -42,7 +64,10 @@ public sealed record FocasAddress(
|
||||
if (src.StartsWith("MACRO:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["MACRO:".Length..], FocasAreaKind.Macro, bitSeparator: null);
|
||||
|
||||
// PMC path: letter + digits + optional .bit
|
||||
if (src.StartsWith("DIAG:", StringComparison.OrdinalIgnoreCase))
|
||||
return ParseScoped(src["DIAG:".Length..], FocasAreaKind.Diagnostic, bitSeparator: '/');
|
||||
|
||||
// PMC path: letter + digits + optional @path + optional .bit
|
||||
if (src.Length < 2 || !char.IsLetter(src[0])) return null;
|
||||
var letter = src[0..1].ToUpperInvariant();
|
||||
if (!IsValidPmcLetter(letter)) return null;
|
||||
@@ -57,8 +82,15 @@ public sealed record FocasAddress(
|
||||
bit = bitValue;
|
||||
remainder = remainder[..dotIdx];
|
||||
}
|
||||
var pmcPath = 1;
|
||||
var atIdx = remainder.IndexOf('@');
|
||||
if (atIdx >= 0)
|
||||
{
|
||||
if (!TryParsePathId(remainder[(atIdx + 1)..], out pmcPath)) return null;
|
||||
remainder = remainder[..atIdx];
|
||||
}
|
||||
if (!int.TryParse(remainder, out var number) || number < 0) return null;
|
||||
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit);
|
||||
return new FocasAddress(FocasAreaKind.Pmc, letter, number, bit, pmcPath);
|
||||
}
|
||||
|
||||
private static FocasAddress? ParseScoped(string body, FocasAreaKind kind, char? bitSeparator)
|
||||
@@ -75,8 +107,30 @@ public sealed record FocasAddress(
|
||||
body = body[..slashIdx];
|
||||
}
|
||||
}
|
||||
// Path suffix (@N) sits between the body number and any bit/axis (which has already
|
||||
// been peeled off above): PARAM:1815@2/0 → body="1815@2", bit=0.
|
||||
var path = 1;
|
||||
var atIdx = body.IndexOf('@');
|
||||
if (atIdx >= 0)
|
||||
{
|
||||
if (!TryParsePathId(body[(atIdx + 1)..], out path)) return null;
|
||||
body = body[..atIdx];
|
||||
}
|
||||
if (!int.TryParse(body, out var number) || number < 0) return null;
|
||||
return new FocasAddress(kind, PmcLetter: null, number, bit);
|
||||
return new FocasAddress(kind, PmcLetter: null, number, bit, path);
|
||||
}
|
||||
|
||||
private static bool TryParsePathId(string text, out int pathId)
|
||||
{
|
||||
// Path 0 is reserved (FOCAS path numbering is 1-based); upper-bound is the FWLIB
|
||||
// ceiling — Fanuc spec lists 10 paths max even on the largest 30i-B configurations.
|
||||
if (int.TryParse(text, out var v) && v is >= 1 and <= 10)
|
||||
{
|
||||
pathId = v;
|
||||
return true;
|
||||
}
|
||||
pathId = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsValidPmcLetter(string letter) => letter switch
|
||||
@@ -92,4 +146,12 @@ public enum FocasAreaKind
|
||||
Pmc,
|
||||
Parameter,
|
||||
Macro,
|
||||
/// <summary>
|
||||
/// CNC diagnostic number routed through <c>cnc_rddiag</c>. <c>DIAG:nnn</c> is a
|
||||
/// whole-CNC diagnostic (axis = 0); <c>DIAG:nnn/axis</c> is per-axis (axis is the
|
||||
/// 1-based FANUC axis index). Like parameters, diagnostics span Int / Float /
|
||||
/// Bit shapes — the driver picks the wire shape based on the configured tag's
|
||||
/// <see cref="FocasDataType"/>.
|
||||
/// </summary>
|
||||
Diagnostic,
|
||||
}
|
||||
|
||||
@@ -32,9 +32,10 @@ public static class FocasCapabilityMatrix
|
||||
|
||||
return address.Kind switch
|
||||
{
|
||||
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
||||
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
||||
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
||||
FocasAreaKind.Macro => ValidateMacro(series, address.Number),
|
||||
FocasAreaKind.Parameter => ValidateParameter(series, address.Number),
|
||||
FocasAreaKind.Pmc => ValidatePmc(series, address.PmcLetter, address.Number),
|
||||
FocasAreaKind.Diagnostic => ValidateDiagnostic(series, address.Number),
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -73,11 +74,35 @@ public static class FocasCapabilityMatrix
|
||||
_ => (0, int.MaxValue),
|
||||
};
|
||||
|
||||
/// <summary>PMC letters accepted per series. Legacy controllers omit F/M/C
|
||||
/// signal groups that 30i-family ladder programs use.</summary>
|
||||
/// <summary>
|
||||
/// CNC diagnostic number range accepted by a series; from <c>cnc_rddiag</c>
|
||||
/// (and <c>cnc_rddiagdgn</c> for axis-scoped reads). Returning <c>null</c>
|
||||
/// means the series doesn't support <c>cnc_rddiag</c> at all — the driver
|
||||
/// rejects every <c>DIAG:</c> address on that series. Conservative ceilings
|
||||
/// per the FOCAS Developer Kit: legacy 16i-family caps at 499; modern 0i-F
|
||||
/// family at 999; 30i / 31i / 32i extend to 1023. Power Motion i has a
|
||||
/// narrow diagnostic surface (0..255).
|
||||
/// </summary>
|
||||
internal static (int min, int max)? DiagnosticRange(FocasCncSeries series) => series switch
|
||||
{
|
||||
FocasCncSeries.Sixteen_i => (0, 499),
|
||||
FocasCncSeries.Zero_i_D => (0, 499),
|
||||
FocasCncSeries.Zero_i_F or
|
||||
FocasCncSeries.Zero_i_MF or
|
||||
FocasCncSeries.Zero_i_TF => (0, 999),
|
||||
FocasCncSeries.Thirty_i or
|
||||
FocasCncSeries.ThirtyOne_i or
|
||||
FocasCncSeries.ThirtyTwo_i => (0, 1023),
|
||||
FocasCncSeries.PowerMotion_i => (0, 255),
|
||||
_ => (0, int.MaxValue),
|
||||
};
|
||||
|
||||
/// <summary>PMC letters accepted per series. Legacy 16i ladders use X/Y/F/G
|
||||
/// for handshakes plus R/D for retained/data; M/C/E/A/K/T are the 0i-F /
|
||||
/// 30i-family extensions.</summary>
|
||||
internal static IReadOnlySet<string> PmcLetters(FocasCncSeries series) => series switch
|
||||
{
|
||||
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D" },
|
||||
FocasCncSeries.Sixteen_i => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "F", "G", "R", "D" },
|
||||
FocasCncSeries.Zero_i_D => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "X", "Y", "R", "D", "E", "A" },
|
||||
FocasCncSeries.Zero_i_F or
|
||||
FocasCncSeries.Zero_i_MF or
|
||||
@@ -106,6 +131,27 @@ public static class FocasCapabilityMatrix
|
||||
_ => int.MaxValue,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether the FOCAS driver should expose the per-device <c>Tooling/</c>
|
||||
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
|
||||
/// <c>cnc_rdtnum</c>, which is documented for every modern Fanuc series
|
||||
/// (0i / 16i / 30i families) — defaulting to <c>true</c>. The capability
|
||||
/// hook exists so a future controller without <c>cnc_rdtnum</c> can opt
|
||||
/// out without touching the driver. <see cref="FocasCncSeries.Unknown"/>
|
||||
/// stays permissive (matches the modal / override fixed-tree precedent in
|
||||
/// issue #259). Issue #260.
|
||||
/// </summary>
|
||||
public static bool SupportsTooling(FocasCncSeries series) => true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the FOCAS driver should expose the per-device <c>Offsets/</c>
|
||||
/// fixed-tree subfolder for a given <paramref name="series"/>. Backed by
|
||||
/// <c>cnc_rdzofs(n=1..6)</c> for the standard G54..G59 surfaces; extended
|
||||
/// G54.1 P1..P48 surfaces are deferred to a follow-up. Same permissive
|
||||
/// policy as <see cref="SupportsTooling"/>. Issue #260.
|
||||
/// </summary>
|
||||
public static bool SupportsWorkOffsets(FocasCncSeries series) => true;
|
||||
|
||||
private static string? ValidateMacro(FocasCncSeries series, int number)
|
||||
{
|
||||
var (min, max) = MacroRange(series);
|
||||
@@ -122,6 +168,16 @@ public static class FocasCapabilityMatrix
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? ValidateDiagnostic(FocasCncSeries series, int number)
|
||||
{
|
||||
if (DiagnosticRange(series) is not { } range)
|
||||
return $"Diagnostic addresses are not supported on {series} (no documented cnc_rddiag range).";
|
||||
var (min, max) = range;
|
||||
return (number < min || number > max)
|
||||
? $"Diagnostic #{number} is outside the documented range [{min}, {max}] for {series}."
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? ValidatePmc(FocasCncSeries series, string? letter, int number)
|
||||
{
|
||||
if (string.IsNullOrEmpty(letter)) return "PMC address is missing its letter prefix.";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,22 +11,55 @@ public sealed class FocasDriverOptions
|
||||
public IReadOnlyList<FocasTagDefinition> Tags { get; init; } = [];
|
||||
public FocasProbeOptions Probe { get; init; } = new();
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Fixed-tree behaviour knobs (issue #262, plan PR F1-f). Carries the
|
||||
/// <c>ApplyFigureScaling</c> toggle that gates the <c>cnc_getfigure</c>
|
||||
/// decimal-place division applied to position values before publishing.
|
||||
/// </summary>
|
||||
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-driver fixed-tree options. New installs default <see cref="ApplyFigureScaling"/>
|
||||
/// to <c>true</c> so position values surface in user units (mm / inch). Existing
|
||||
/// deployments that already published raw scaled integers can flip this to <c>false</c>
|
||||
/// for migration parity — the operator-facing concern is that switching the flag
|
||||
/// mid-deployment changes the values clients see, so the migration path is
|
||||
/// documentation-only (issue #262).
|
||||
/// </summary>
|
||||
public sealed record FocasFixedTreeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// When <c>true</c> (default), position values from <c>cnc_absolute</c> /
|
||||
/// <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> /
|
||||
/// <c>cnc_actf</c> are divided by <c>10^decimalPlaces</c> per axis using the
|
||||
/// <c>cnc_getfigure</c> snapshot cached at probe time. When <c>false</c>, the
|
||||
/// raw integer values are published unchanged — used for migrations from
|
||||
/// older drivers that didn't apply the scaling.
|
||||
/// </summary>
|
||||
public bool ApplyFigureScaling { get; init; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One CNC the driver talks to. <paramref name="Series"/> enables per-series
|
||||
/// address validation at <see cref="FocasDriver.InitializeAsync"/>; leave as
|
||||
/// <see cref="FocasCncSeries.Unknown"/> to skip validation (legacy behaviour).
|
||||
/// <paramref name="OverrideParameters"/> declares the four MTB-specific override
|
||||
/// <c>cnc_rdparam</c> numbers surfaced under <c>Override/</c>; pass <c>null</c> to
|
||||
/// suppress the entire <c>Override/</c> subfolder for that device (issue #259).
|
||||
/// </summary>
|
||||
public sealed record FocasDeviceOptions(
|
||||
string HostAddress,
|
||||
string? DeviceName = null,
|
||||
FocasCncSeries Series = FocasCncSeries.Unknown);
|
||||
FocasCncSeries Series = FocasCncSeries.Unknown,
|
||||
FocasOverrideParameters? OverrideParameters = null);
|
||||
|
||||
/// <summary>
|
||||
/// One FOCAS-backed OPC UA variable. <paramref name="Address"/> is the canonical FOCAS
|
||||
/// address string that parses via <see cref="FocasAddress.TryParse"/> —
|
||||
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c>.
|
||||
/// <c>X0.0</c> / <c>R100</c> / <c>PARAM:1815/0</c> / <c>MACRO:500</c> /
|
||||
/// <c>DIAG:1031</c> / <c>DIAG:280/2</c>.
|
||||
/// </summary>
|
||||
public sealed record FocasTagDefinition(
|
||||
string Name,
|
||||
|
||||
@@ -59,10 +59,20 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
FocasAreaKind.Pmc => Task.FromResult(ReadPmc(address, type)),
|
||||
FocasAreaKind.Parameter => Task.FromResult(ReadParameter(address, type)),
|
||||
FocasAreaKind.Macro => Task.FromResult(ReadMacro(address)),
|
||||
FocasAreaKind.Diagnostic => Task.FromResult(
|
||||
ReadDiagnostic(address.Number, address.BitIndex ?? 0, type)),
|
||||
_ => Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported)),
|
||||
};
|
||||
}
|
||||
|
||||
public Task<(object? value, uint status)> ReadDiagnosticAsync(
|
||||
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadCommunicationError));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(ReadDiagnostic(diagNumber, axisOrZero, type));
|
||||
}
|
||||
|
||||
public async Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -129,6 +139,26 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
}
|
||||
}
|
||||
|
||||
public Task<int> GetPathCountAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(1);
|
||||
var buf = new FwlibNative.ODBPATH();
|
||||
var ret = FwlibNative.RdPathNum(_handle, ref buf);
|
||||
// EW_FUNC / EW_NOOPT on single-path controllers — fall back to 1 rather than failing.
|
||||
if (ret != 0 || buf.MaxPath < 1) return Task.FromResult(1);
|
||||
return Task.FromResult((int)buf.MaxPath);
|
||||
}
|
||||
|
||||
public Task SetPathAsync(int pathId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.CompletedTask;
|
||||
var ret = FwlibNative.SetPath(_handle, (short)pathId);
|
||||
if (ret != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"FWLIB cnc_setpath failed with EW_{ret} switching to path {pathId}.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult(false);
|
||||
@@ -137,6 +167,256 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
return Task.FromResult(ret == 0);
|
||||
}
|
||||
|
||||
public Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasStatusInfo?>(null);
|
||||
var buf = new FwlibNative.ODBST();
|
||||
var ret = FwlibNative.StatInfo(_handle, ref buf);
|
||||
if (ret != 0) return Task.FromResult<FocasStatusInfo?>(null);
|
||||
return Task.FromResult<FocasStatusInfo?>(new FocasStatusInfo(
|
||||
Dummy: buf.Dummy,
|
||||
Tmmode: buf.TmMode,
|
||||
Aut: buf.Aut,
|
||||
Run: buf.Run,
|
||||
Motion: buf.Motion,
|
||||
Mstb: buf.Mstb,
|
||||
EmergencyStop: buf.Emergency,
|
||||
Alarm: buf.Alarm,
|
||||
Edit: buf.Edit));
|
||||
}
|
||||
|
||||
public Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasProductionInfo?>(null);
|
||||
if (!TryReadInt32Param(6711, out var produced) ||
|
||||
!TryReadInt32Param(6712, out var required) ||
|
||||
!TryReadInt32Param(6713, out var total))
|
||||
{
|
||||
return Task.FromResult<FocasProductionInfo?>(null);
|
||||
}
|
||||
// Cycle-time timer (type=2). Total seconds = minute*60 + msec/1000. Best-effort:
|
||||
// a non-zero return leaves cycle-time at 0 rather than failing the whole snapshot
|
||||
// — the parts counters are still useful even when cycle-time isn't supported.
|
||||
var cycleSeconds = 0;
|
||||
var tmrBuf = new FwlibNative.IODBTMR();
|
||||
if (FwlibNative.RdTimer(_handle, type: 2, ref tmrBuf) == 0)
|
||||
cycleSeconds = checked(tmrBuf.Minute * 60 + tmrBuf.Msec / 1000);
|
||||
return Task.FromResult<FocasProductionInfo?>(new FocasProductionInfo(
|
||||
PartsProduced: produced,
|
||||
PartsRequired: required,
|
||||
PartsTotal: total,
|
||||
CycleTimeSeconds: cycleSeconds));
|
||||
}
|
||||
|
||||
private bool TryReadInt32Param(ushort number, out int value)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 4, ref buf);
|
||||
if (ret != 0) { value = 0; return false; }
|
||||
value = BinaryPrimitives.ReadInt32LittleEndian(buf.Data);
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryReadInt16Param(ushort number, out short value)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var ret = FwlibNative.RdParam(_handle, number, axis: 0, length: 4 + 2, ref buf);
|
||||
if (ret != 0) { value = 0; return false; }
|
||||
value = BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
|
||||
return true;
|
||||
}
|
||||
|
||||
public Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasModalInfo?>(null);
|
||||
// type 100/101/102/103 = M/S/T/B (single auxiliary code, active modal block 0).
|
||||
// Best-effort — if any single read fails we still surface the others as 0; the
|
||||
// probe loop only updates the cache on a non-null return so a partial snapshot
|
||||
// is preferable to throwing away every successful field.
|
||||
return Task.FromResult<FocasModalInfo?>(new FocasModalInfo(
|
||||
MCode: ReadModalAux(type: 100),
|
||||
SCode: ReadModalAux(type: 101),
|
||||
TCode: ReadModalAux(type: 102),
|
||||
BCode: ReadModalAux(type: 103)));
|
||||
}
|
||||
|
||||
private short ReadModalAux(short type)
|
||||
{
|
||||
var buf = new FwlibNative.ODBMDL { Data = new byte[8] };
|
||||
var ret = FwlibNative.Modal(_handle, type, block: 0, ref buf);
|
||||
if (ret != 0) return 0;
|
||||
// For aux types (100..103) the union holds the code at offset 0 as a 2-byte
|
||||
// value (<c>aux_data</c>). Reading as Int16 keeps the surface identical to the
|
||||
// record contract; oversized values would have been truncated by FWLIB anyway.
|
||||
return BinaryPrimitives.ReadInt16LittleEndian(buf.Data);
|
||||
}
|
||||
|
||||
public Task<FocasOverrideInfo?> GetOverrideAsync(
|
||||
FocasOverrideParameters parameters, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasOverrideInfo?>(null);
|
||||
// Each parameter is independently nullable — a null parameter number keeps the
|
||||
// corresponding field at null + skips the wire call. A successful read on at
|
||||
// least one parameter is enough to publish a snapshot; this matches the
|
||||
// best-effort policy used by GetProductionAsync (issue #259).
|
||||
var feed = TryReadOverride(parameters.FeedParam);
|
||||
var rapid = TryReadOverride(parameters.RapidParam);
|
||||
var spindle = TryReadOverride(parameters.SpindleParam);
|
||||
var jog = TryReadOverride(parameters.JogParam);
|
||||
return Task.FromResult<FocasOverrideInfo?>(new FocasOverrideInfo(feed, rapid, spindle, jog));
|
||||
}
|
||||
|
||||
private short? TryReadOverride(ushort? param)
|
||||
{
|
||||
if (param is null) return null;
|
||||
return TryReadInt16Param(param.Value, out var v) ? v : null;
|
||||
}
|
||||
|
||||
public Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasToolingInfo?>(null);
|
||||
var buf = new FwlibNative.IODBTNUM();
|
||||
var ret = FwlibNative.RdToolNumber(_handle, ref buf);
|
||||
if (ret != 0) return Task.FromResult<FocasToolingInfo?>(null);
|
||||
// FWLIB returns long; clamp to short for the surfaced Int16 (T-codes
|
||||
// overflowing 32767 are vanishingly rare on Fanuc tool tables).
|
||||
var t = buf.Data;
|
||||
if (t > short.MaxValue) t = short.MaxValue;
|
||||
else if (t < short.MinValue) t = short.MinValue;
|
||||
return Task.FromResult<FocasToolingInfo?>(new FocasToolingInfo((short)t));
|
||||
}
|
||||
|
||||
public Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasWorkOffsetsInfo?>(null);
|
||||
|
||||
// 1..6 = G54..G59. Extended G54.1 P1..P48 use cnc_rdzofsr and are deferred.
|
||||
// Pass axis=-1 so FWLIB fills every axis it has; we read the first 3 (X/Y/Z).
|
||||
// Length = 4-byte header + 3 axes * 10-byte OFSB = 34. We request 4 + 8*10 = 84
|
||||
// (the buffer ceiling) so a CNC with more axes still completes the call.
|
||||
var slots = new List<FocasWorkOffset>(6);
|
||||
string[] names = ["G54", "G55", "G56", "G57", "G58", "G59"];
|
||||
for (short n = 1; n <= 6; n++)
|
||||
{
|
||||
var buf = new FwlibNative.IODBZOFS { Data = new byte[80] };
|
||||
var ret = FwlibNative.RdWorkOffset(_handle, n, axis: -1, length: 4 + 8 * 10, ref buf);
|
||||
if (ret != 0)
|
||||
{
|
||||
// Best-effort — a single-slot failure leaves the slot at 0.0; the cache
|
||||
// still publishes so reads on the other offsets serve Good. The probe
|
||||
// loop will retry on the next tick.
|
||||
slots.Add(new FocasWorkOffset(names[n - 1], 0, 0, 0));
|
||||
continue;
|
||||
}
|
||||
slots.Add(new FocasWorkOffset(
|
||||
Name: names[n - 1],
|
||||
X: DecodeOfsbAxis(buf.Data, axisIndex: 0),
|
||||
Y: DecodeOfsbAxis(buf.Data, axisIndex: 1),
|
||||
Z: DecodeOfsbAxis(buf.Data, axisIndex: 2)));
|
||||
}
|
||||
return Task.FromResult<FocasWorkOffsetsInfo?>(new FocasWorkOffsetsInfo(slots));
|
||||
}
|
||||
|
||||
public Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasOperatorMessagesInfo?>(null);
|
||||
// type 0..3 = OPMSG / MACRO / EXTERN / REJ-EXT (issue #261). Single-slot read
|
||||
// (length 4 + 256 = 260) returns the most-recent message in each class — best-
|
||||
// effort: a single-class failure leaves that class out of the snapshot rather
|
||||
// than failing the whole call, mirroring GetProductionAsync's policy.
|
||||
var list = new List<FocasOperatorMessage>(4);
|
||||
string[] classNames = ["OPMSG", "MACRO", "EXTERN", "REJ-EXT"];
|
||||
for (short t = 0; t < 4; t++)
|
||||
{
|
||||
var buf = new FwlibNative.OPMSG3 { Data = new byte[256] };
|
||||
var ret = FwlibNative.RdOpMsg3(_handle, t, length: 4 + 256, ref buf);
|
||||
if (ret != 0) continue;
|
||||
var text = TrimAnsiPadding(buf.Data);
|
||||
if (string.IsNullOrEmpty(text)) continue;
|
||||
list.Add(new FocasOperatorMessage(buf.Datano, classNames[t], text));
|
||||
}
|
||||
return Task.FromResult<FocasOperatorMessagesInfo?>(new FocasOperatorMessagesInfo(list));
|
||||
}
|
||||
|
||||
public Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||
var buf = new FwlibNative.ODBACTPT { Data = new byte[256] };
|
||||
var ret = FwlibNative.RdActPt(_handle, ref buf);
|
||||
if (ret != 0) return Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||
return Task.FromResult<FocasCurrentBlockInfo?>(
|
||||
new FocasCurrentBlockInfo(TrimAnsiPadding(buf.Data)));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
||||
// kind=0 → position figures (absolute/relative/machine/distance share the same
|
||||
// increment system per axis). cnc_rdaxisname is deferred — the wire impl keys
|
||||
// by fallback "axis{n}" (1-based), the driver re-keys when it gains axis-name
|
||||
// discovery in a follow-up. Issue #262, plan PR F1-f.
|
||||
short count = 0;
|
||||
var buf = new FwlibNative.IODBAXIS { Data = new byte[FwlibNative.MAX_AXIS * 8] };
|
||||
var ret = FwlibNative.GetFigure(_handle, kind: 0, ref count, ref buf);
|
||||
if (ret != 0) return Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
||||
return Task.FromResult<IReadOnlyDictionary<string, int>?>(DecodeFigureScaling(buf.Data, count));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode the per-axis decimal-place counts from a <c>cnc_getfigure</c> reply
|
||||
/// buffer. Each axis entry per <c>fwlib32.h</c> is 8 bytes laid out as
|
||||
/// <c>short dec</c> + <c>short unit</c> + 4 reserved bytes; we read only
|
||||
/// <c>dec</c>. Keys are 1-based <c>"axis{n}"</c> placeholders — a follow-up
|
||||
/// PR can rewire to <c>cnc_rdaxisname</c> once that surface lands without
|
||||
/// changing the cache contract (issue #262).
|
||||
/// </summary>
|
||||
internal static IReadOnlyDictionary<string, int> DecodeFigureScaling(byte[] data, short count)
|
||||
{
|
||||
var clamped = Math.Max((short)0, Math.Min(count, (short)FwlibNative.MAX_AXIS));
|
||||
var result = new Dictionary<string, int>(clamped, StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < clamped; i++)
|
||||
{
|
||||
var offset = i * 8;
|
||||
if (offset + 2 > data.Length) break;
|
||||
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset, 2));
|
||||
if (dec < 0 || dec > 9) dec = 0;
|
||||
result[$"axis{i + 1}"] = dec;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode + trim a Fanuc ANSI byte buffer. The CNC right-pads block text + opmsg
|
||||
/// bodies with nulls or spaces; trim them so the round-trip through the OPC UA
|
||||
/// address space stays stable (issue #261). Stops at the first NUL so any wire
|
||||
/// buffer that gets reused doesn't leak old bytes.
|
||||
/// </summary>
|
||||
internal static string TrimAnsiPadding(byte[] data)
|
||||
{
|
||||
if (data is null) return string.Empty;
|
||||
var len = 0;
|
||||
for (; len < data.Length; len++)
|
||||
if (data[len] == 0) break;
|
||||
return System.Text.Encoding.ASCII.GetString(data, 0, len).TrimEnd(' ', '\0');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode one OFSB axis block from a <c>cnc_rdzofs</c> data buffer. Each axis
|
||||
/// occupies 10 bytes per <c>fwlib32.h</c>: <c>int data</c> + <c>short dec</c> +
|
||||
/// <c>short unit</c> + <c>short disp</c>. The user-facing offset is
|
||||
/// <c>data / 10^dec</c> — same convention as <c>cnc_rdmacro</c>.
|
||||
/// </summary>
|
||||
internal static double DecodeOfsbAxis(byte[] data, int axisIndex)
|
||||
{
|
||||
const int blockSize = 10;
|
||||
var offset = axisIndex * blockSize;
|
||||
if (offset + blockSize > data.Length) return 0;
|
||||
var raw = BinaryPrimitives.ReadInt32LittleEndian(data.AsSpan(offset, 4));
|
||||
var dec = BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(offset + 4, 2));
|
||||
if (dec < 0 || dec > 9) dec = 0;
|
||||
return raw / Math.Pow(10.0, dec);
|
||||
}
|
||||
|
||||
// ---- PMC ----
|
||||
|
||||
private (object? value, uint status) ReadPmc(FocasAddress address, FocasDataType type)
|
||||
@@ -165,6 +445,42 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
return (value, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Range read for the PMC coalescer (issue #266). FWLIB's <c>pmc_rdpmcrng</c>
|
||||
/// payload is capped at 40 bytes (the IODBPMC.Data union width), so requested
|
||||
/// ranges larger than that are chunked into 32-byte sub-calls internally —
|
||||
/// callers still see one logical range, which matches the
|
||||
/// <see cref="Wire.FocasPmcCoalescer"/>'s "one wire call per group" semantics.
|
||||
/// </summary>
|
||||
public Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
|
||||
string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_connected) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.BadCommunicationError));
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (byteCount <= 0) return Task.FromResult<(byte[]?, uint)>((Array.Empty<byte>(), FocasStatusMapper.Good));
|
||||
|
||||
var addrType = FocasPmcAddrType.FromLetter(letter)
|
||||
?? throw new InvalidOperationException($"Unknown PMC letter '{letter}'.");
|
||||
var result = new byte[byteCount];
|
||||
const int chunkBytes = 32;
|
||||
var offset = 0;
|
||||
while (offset < byteCount)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var thisChunk = Math.Min(chunkBytes, byteCount - offset);
|
||||
var buf = new FwlibNative.IODBPMC { Data = new byte[40] };
|
||||
var ret = FwlibNative.PmcRdPmcRng(
|
||||
_handle, addrType, FocasPmcDataType.Byte,
|
||||
(ushort)(startByte + offset),
|
||||
(ushort)(startByte + offset + thisChunk - 1),
|
||||
(ushort)(8 + thisChunk), ref buf);
|
||||
if (ret != 0) return Task.FromResult<(byte[]?, uint)>((null, FocasStatusMapper.MapFocasReturn(ret)));
|
||||
Array.Copy(buf.Data, 0, result, offset, thisChunk);
|
||||
offset += thisChunk;
|
||||
}
|
||||
return Task.FromResult<(byte[]?, uint)>((result, FocasStatusMapper.Good));
|
||||
}
|
||||
|
||||
private uint WritePmc(FocasAddress address, FocasDataType type, object? value)
|
||||
{
|
||||
var addrType = FocasPmcAddrType.FromLetter(address.PmcLetter ?? "") ?? (short)0;
|
||||
@@ -217,6 +533,36 @@ internal sealed class FwlibFocasClient : IFocasClient
|
||||
return ret == 0 ? FocasStatusMapper.Good : FocasStatusMapper.MapFocasReturn(ret);
|
||||
}
|
||||
|
||||
private (object? value, uint status) ReadDiagnostic(int diagNumber, int axisOrZero, FocasDataType type)
|
||||
{
|
||||
var buf = new FwlibNative.IODBPSD { Data = new byte[32] };
|
||||
var length = DiagnosticReadLength(type);
|
||||
var ret = FwlibNative.RdDiag(_handle, (ushort)diagNumber, (short)axisOrZero, (short)length, ref buf);
|
||||
if (ret != 0) return (null, FocasStatusMapper.MapFocasReturn(ret));
|
||||
|
||||
var value = type switch
|
||||
{
|
||||
FocasDataType.Bit => (object)ExtractBit(buf.Data[0], 0),
|
||||
FocasDataType.Byte => (object)(sbyte)buf.Data[0],
|
||||
FocasDataType.Int16 => (object)BinaryPrimitives.ReadInt16LittleEndian(buf.Data),
|
||||
FocasDataType.Int32 => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
FocasDataType.Float32 => (object)BinaryPrimitives.ReadSingleLittleEndian(buf.Data),
|
||||
FocasDataType.Float64 => (object)BinaryPrimitives.ReadDoubleLittleEndian(buf.Data),
|
||||
_ => (object)BinaryPrimitives.ReadInt32LittleEndian(buf.Data),
|
||||
};
|
||||
return (value, FocasStatusMapper.Good);
|
||||
}
|
||||
|
||||
private static int DiagnosticReadLength(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => 4 + 1,
|
||||
FocasDataType.Int16 => 4 + 2,
|
||||
FocasDataType.Int32 => 4 + 4,
|
||||
FocasDataType.Float32 => 4 + 4,
|
||||
FocasDataType.Float64 => 4 + 8,
|
||||
_ => 4 + 4,
|
||||
};
|
||||
|
||||
private (object? value, uint status) ReadMacro(FocasAddress address)
|
||||
{
|
||||
var buf = new FwlibNative.ODBM();
|
||||
|
||||
@@ -88,6 +88,144 @@ internal static class FwlibNative
|
||||
[DllImport(Library, EntryPoint = "cnc_statinfo", ExactSpelling = true)]
|
||||
public static extern short StatInfo(ushort handle, ref ODBST buffer);
|
||||
|
||||
// ---- Timers ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdtimer</c> — read CNC running timers. <paramref name="type"/>: 0 = power-on
|
||||
/// time (ms), 1 = operating time (ms), 2 = cycle time (ms), 3 = cutting time (ms).
|
||||
/// Only the cycle-time variant is consumed today (issue #258); the call is generic
|
||||
/// so the surface can grow without another P/Invoke.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdtimer", ExactSpelling = true)]
|
||||
public static extern short RdTimer(ushort handle, short type, ref IODBTMR buffer);
|
||||
|
||||
// ---- Modal codes ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_modal</c> — read modal information for one G-group or auxiliary code.
|
||||
/// <paramref name="type"/>: 1..21 = G-group N (single group), 100 = M, 101 = S,
|
||||
/// 102 = T, 103 = B (per Fanuc FOCAS reference). <paramref name="block"/>: 0 =
|
||||
/// active modal commands. We only consume types 100..103 today (M/S/T/B); the
|
||||
/// G-group decode is deferred to a follow-up because the <c>ODBMDL</c> union
|
||||
/// varies by group + series (issue #259).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_modal", ExactSpelling = true)]
|
||||
public static extern short Modal(ushort handle, short type, short block, ref ODBMDL buffer);
|
||||
|
||||
// ---- Tooling ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdtnum</c> — read the currently selected tool number. Returns
|
||||
/// <c>EW_OK</c> + populates <see cref="IODBTNUM.Data"/> with the active T-code.
|
||||
/// Tool life + current offset index reads (<c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c>/
|
||||
/// <c>cnc_rdtofs</c>) are deferred per the F1-d plan — those calls use ODBTLIFE*
|
||||
/// unions whose shape varies per series.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdtnum", ExactSpelling = true)]
|
||||
public static extern short RdToolNumber(ushort handle, ref IODBTNUM buffer);
|
||||
|
||||
// ---- Work coordinate offsets ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdzofs</c> — read one work-coordinate offset slot. <paramref name="number"/>:
|
||||
/// 1..6 = G54..G59 (standard). Extended <c>G54.1 P1..P48</c> use <c>cnc_rdzofsr</c>
|
||||
/// and are deferred. <paramref name="axis"/>: -1 = all axes returned, 1..N = single
|
||||
/// axis. <paramref name="length"/>: 12 + (N axes * 8) — we request -1 and let FWLIB
|
||||
/// fill up to <see cref="IODBZOFS.Data"/>'s 8-axis ceiling.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdzofs", ExactSpelling = true)]
|
||||
public static extern short RdWorkOffset(
|
||||
ushort handle,
|
||||
short number,
|
||||
short axis,
|
||||
short length,
|
||||
ref IODBZOFS buffer);
|
||||
|
||||
// ---- Operator messages ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdopmsg3</c> — read FANUC operator messages by class. <paramref name="type"/>:
|
||||
/// 0 = OPMSG (op-msg ladder/macro), 1 = MACRO, 2 = EXTERN (external operator message),
|
||||
/// 3 = REJ-EXT (rejected EXTERN). <paramref name="length"/>: per <c>fwlib32.h</c> the
|
||||
/// buffer is <c>4 + 256 = 260</c> bytes per message slot — single-slot reads (length 260)
|
||||
/// return the most-recent message in that class. Issue #261, plan PR F1-e.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdopmsg3", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
public static extern short RdOpMsg3(
|
||||
ushort handle,
|
||||
short type,
|
||||
short length,
|
||||
ref OPMSG3 buffer);
|
||||
|
||||
// ---- Figure (per-axis decimal scaling) ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_getfigure</c> — read per-axis figure info (decimal-place counts + units).
|
||||
/// <paramref name="kind"/>: 0 = absolute / relative / machine position figures,
|
||||
/// 1 = work-coord shift figures (per Fanuc reference). The reply struct holds
|
||||
/// up to <see cref="MAX_AXIS"/> axis entries; the managed side reads the count
|
||||
/// out via <paramref name="outCount"/>. Position values from <c>cnc_absolute</c>
|
||||
/// / <c>cnc_machine</c> / <c>cnc_relative</c> / <c>cnc_distance</c> / <c>cnc_actf</c>
|
||||
/// are scaled integers — divide by <c>10^figureinfo[axis].dec</c> for user units
|
||||
/// (issue #262, plan PR F1-f).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_getfigure", ExactSpelling = true)]
|
||||
public static extern short GetFigure(
|
||||
ushort handle,
|
||||
short kind,
|
||||
ref short outCount,
|
||||
ref IODBAXIS figureinfo);
|
||||
|
||||
// ---- Diagnostics ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rddiag</c> — read a CNC diagnostic value. <paramref name="number"/> is the
|
||||
/// diagnostic number (e.g. 1031 = current alarm cause); <paramref name="axis"/> is 0
|
||||
/// for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics.
|
||||
/// <paramref name="length"/> is sized like <see cref="RdParam"/> — 4-byte header +
|
||||
/// widest payload (8 bytes for Float64). The shape of the payload depends on the
|
||||
/// diagnostic; the managed side decodes via <see cref="FocasDataType"/> on the
|
||||
/// configured tag (issue #263).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rddiag", ExactSpelling = true)]
|
||||
public static extern short RdDiag(
|
||||
ushort handle,
|
||||
ushort number,
|
||||
short axis,
|
||||
short length,
|
||||
ref IODBPSD buffer);
|
||||
|
||||
// ---- Multi-path / multi-channel ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdpathnum</c> — read the number of CNC paths (channels) the controller
|
||||
/// exposes + the currently-active path. Multi-path CNCs (lathe + sub-spindle,
|
||||
/// dual-turret) return 2..N; single-path CNCs return 1. The driver caches
|
||||
/// <see cref="ODBPATH.MaxPath"/> at connect and uses it to validate per-tag
|
||||
/// <c>PathId</c> values (issue #264).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdpathnum", ExactSpelling = true)]
|
||||
public static extern short RdPathNum(ushort handle, ref ODBPATH buffer);
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_setpath</c> — switch the active CNC path (channel) for subsequent
|
||||
/// calls. <paramref name="path"/> is 1-based. The driver issues this before
|
||||
/// every read whose path differs from the last one set on the session;
|
||||
/// single-path tags (PathId=1 only) skip the call entirely (issue #264).
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_setpath", ExactSpelling = true)]
|
||||
public static extern short SetPath(ushort handle, short path);
|
||||
|
||||
// ---- Currently-executing block ----
|
||||
|
||||
/// <summary>
|
||||
/// <c>cnc_rdactpt</c> — read the currently-executing program block text. The
|
||||
/// reply struct holds the program / sequence numbers + the active block as a
|
||||
/// null-padded ASCII string. Issue #261, plan PR F1-e.
|
||||
/// </summary>
|
||||
[DllImport(Library, EntryPoint = "cnc_rdactpt", CharSet = CharSet.Ansi, ExactSpelling = true)]
|
||||
public static extern short RdActPt(ushort handle, ref ODBACTPT buffer);
|
||||
|
||||
// ---- Structs ----
|
||||
|
||||
/// <summary>
|
||||
@@ -129,6 +267,134 @@ internal static class FwlibNative
|
||||
public short DecVal; // decimal-point count
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBTMR — running-timer read buffer per <c>fwlib32.h</c>. Minute portion in
|
||||
/// <see cref="Minute"/>; sub-minute remainder in milliseconds in <see cref="Msec"/>.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBTMR
|
||||
{
|
||||
public int Minute;
|
||||
public int Msec;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ODBMDL — single-group modal read buffer. 4-byte header + a 4-byte union which we
|
||||
/// marshal as a fixed byte array. For type=100..103 (M/S/T/B) the union holds an
|
||||
/// <c>int aux_data</c> at offset 0; we read the first <c>short</c> for symmetry with
|
||||
/// the FWLIB <c>g_modal.aux_data</c> width on G-group reads. The G-group decode
|
||||
/// (type=1..21) is deferred — see <see cref="Modal"/> for context (issue #259).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBMDL
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBTNUM — current tool number read buffer. <see cref="Data"/> holds the active
|
||||
/// T-code (Fanuc reference uses <c>long</c>; we narrow to <c>short</c> on the
|
||||
/// managed side because <see cref="FocasToolingInfo.CurrentTool"/> surfaces as
|
||||
/// <c>Int16</c>). Issue #260, F1-d.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBTNUM
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
public int Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IODBZOFS — work-coordinate offset read buffer. 4-byte header + per-axis
|
||||
/// <c>OFSB</c> blocks (8 bytes each: 4-byte signed integer <c>data</c> + 2-byte
|
||||
/// <c>dec</c> decimal-point count + 2-byte <c>unit</c> + 2-byte <c>disp</c>).
|
||||
/// We marshal a fixed ceiling of 8 axes (= 64 bytes); the managed side reads
|
||||
/// only the first 3 (X / Y / Z) per the F1-d effort sizing.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBZOFS
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
// Up to 8 axes * 8 bytes per OFSB = 64 bytes. Each block: int data, short dec,
|
||||
// short unit, short disp (10 bytes per fwlib32.h). We size for the worst case.
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 80)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OPMSG3 — single-slot operator-message read buffer per <c>fwlib32.h</c>. Per Fanuc
|
||||
/// reference: <c>short datano</c> + <c>short type</c> + <c>char data[256]</c>. The
|
||||
/// text is null-terminated + space-padded; the managed side trims trailing nulls /
|
||||
/// spaces before publishing. Length = 4 + 256 = 260 bytes; total 256 wide enough
|
||||
/// for the longest documented operator message body (issue #261).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct OPMSG3
|
||||
{
|
||||
public short Datano;
|
||||
public short Type;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ODBACTPT — current-block read buffer per <c>fwlib32.h</c>. Per Fanuc reference:
|
||||
/// <c>long o_no</c> (currently active O-number) + <c>long n_no</c> (sequence) +
|
||||
/// <c>char data[256]</c> (active block text). The text is null-terminated +
|
||||
/// space-padded; trimmed before publishing for stable round-trip (issue #261).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBACTPT
|
||||
{
|
||||
public int ONo;
|
||||
public int NNo;
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 256)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maximum axis count per the FWLIB <c>fwlib32.h</c> ceiling for figure-info reads.
|
||||
/// Real Fanuc CNCs cap at 8 simultaneous axes for most series; we marshal an
|
||||
/// 8-entry array (matches <see cref="IODBAXIS"/>) so the call completes regardless
|
||||
/// of the deployment's axis count (issue #262).
|
||||
/// </summary>
|
||||
public const int MAX_AXIS = 8;
|
||||
|
||||
/// <summary>
|
||||
/// IODBAXIS — per-axis figure info read buffer for <c>cnc_getfigure</c>. Each
|
||||
/// axis entry carries the decimal-place count (<c>dec</c>) the CNC reports for
|
||||
/// that axis's increment system + a unit code. The managed side reads the first
|
||||
/// <c>outCount</c> entries returned by FWLIB; we marshal a fixed 8-entry ceiling
|
||||
/// (issue #262, plan PR F1-f).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct IODBAXIS
|
||||
{
|
||||
// Each entry per fwlib32.h is { short dec, short unit, short reserved, short reserved2 }
|
||||
// = 8 bytes. 8 axes * 8 bytes = 64 bytes; we marshal a fixed byte buffer + decode on
|
||||
// the managed side so axis-count growth doesn't churn the P/Invoke surface.
|
||||
[MarshalAs(UnmanagedType.ByValArray, SizeConst = 8 * 8)]
|
||||
public byte[] Data;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ODBPATH — <c>cnc_rdpathnum</c> reply. <see cref="PathNo"/> is the currently-active
|
||||
/// path (1-based); <see cref="MaxPath"/> is the controller's path count. We consume
|
||||
/// <see cref="MaxPath"/> at bootstrap to validate per-tag PathId; runtime path
|
||||
/// selection happens via <see cref="SetPath"/> (issue #264).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBPATH
|
||||
{
|
||||
public short PathNo;
|
||||
public short MaxPath;
|
||||
}
|
||||
|
||||
/// <summary>ODBST — CNC status info. Machine state, alarm flags, automatic / edit mode.</summary>
|
||||
[StructLayout(LayoutKind.Sequential, Pack = 1)]
|
||||
public struct ODBST
|
||||
|
||||
@@ -48,8 +48,311 @@ public interface IFocasClient : IDisposable
|
||||
/// responds with any valid status.
|
||||
/// </summary>
|
||||
Task<bool> ProbeAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Read the full <c>cnc_rdcncstat</c> ODBST struct (9 small-int status flags). The
|
||||
/// boolean <see cref="ProbeAsync"/> is preserved for cheap reachability checks; this
|
||||
/// method exposes the per-field detail used by the FOCAS driver's <c>Status/</c>
|
||||
/// fixed-tree nodes (see issue #257). Returns <c>null</c> if the wire client cannot
|
||||
/// supply the struct (e.g. transport/IPC variant where the contract has not been
|
||||
/// extended yet) — callers fall back to surfacing Bad on the per-field nodes.
|
||||
/// </summary>
|
||||
Task<FocasStatusInfo?> GetStatusAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasStatusInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the per-CNC production counters (parts produced / required / total via
|
||||
/// <c>cnc_rdparam(6711/6712/6713)</c>) plus the current cycle-time seconds counter
|
||||
/// (<c>cnc_rdtimer(2)</c>). Surfaced on the FOCAS driver's <c>Production/</c>
|
||||
/// fixed-tree per device (issue #258). Returns <c>null</c> when the wire client
|
||||
/// cannot supply the snapshot (e.g. older transport variant) — the driver leaves
|
||||
/// the cache untouched and the per-field nodes report Bad until the first refresh.
|
||||
/// </summary>
|
||||
Task<FocasProductionInfo?> GetProductionAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasProductionInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the active modal M/S/T/B codes via <c>cnc_modal</c>. G-group decoding is
|
||||
/// deferred — the FWLIB <c>ODBMDL</c> union differs per series + group and the
|
||||
/// issue body permits surfacing only the universally-present M/S/T/B fields in
|
||||
/// the first cut (issue #259). Returns <c>null</c> when the wire client cannot
|
||||
/// supply the snapshot.
|
||||
/// </summary>
|
||||
Task<FocasModalInfo?> GetModalAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasModalInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the four operator override values (feed / rapid / spindle / jog) via
|
||||
/// <c>cnc_rdparam</c>. The parameter numbers are MTB-specific so the caller passes
|
||||
/// them in via <paramref name="parameters"/>; a <c>null</c> entry suppresses that
|
||||
/// field's read (the corresponding node is also omitted from the address space).
|
||||
/// Returns <c>null</c> when the wire client cannot supply the snapshot (issue #259).
|
||||
/// </summary>
|
||||
Task<FocasOverrideInfo?> GetOverrideAsync(
|
||||
FocasOverrideParameters parameters, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasOverrideInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the current tool number via <c>cnc_rdtnum</c>. Surfaced on the FOCAS driver's
|
||||
/// <c>Tooling/</c> fixed-tree per device (issue #260). Tool life + current offset
|
||||
/// index are deferred — <c>cnc_rdtlinfo</c>/<c>cnc_rdtlsts</c> vary heavily across
|
||||
/// CNC series + the FWLIB <c>ODBTLIFE*</c> unions need per-series shape handling
|
||||
/// that exceeds the L-sized scope of this PR. Returns <c>null</c> when the wire
|
||||
/// client cannot supply the snapshot (e.g. older transport variant).
|
||||
/// </summary>
|
||||
Task<FocasToolingInfo?> GetToolingAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasToolingInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the standard G54..G59 work-coordinate offsets via
|
||||
/// <c>cnc_rdzofs(handle, n=1..6)</c>. Returns one <see cref="FocasWorkOffset"/>
|
||||
/// per slot (issue #260). Extended G54.1 P1..P48 offsets are deferred — they use
|
||||
/// a different FOCAS call (<c>cnc_rdzofsr</c>) + different range handling. Each
|
||||
/// offset surfaces a fixed X/Y/Z view; lathes/mills with extra rotational axes
|
||||
/// have those columns reported as 0.0. Returns <c>null</c> when the wire client
|
||||
/// cannot supply the snapshot.
|
||||
/// </summary>
|
||||
Task<FocasWorkOffsetsInfo?> GetWorkOffsetsAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasWorkOffsetsInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the four FANUC operator-message classes via <c>cnc_rdopmsg3</c> (issue #261).
|
||||
/// The call returns up to 4 active messages per class; the driver collapses the
|
||||
/// latest non-empty message per class onto the <c>Messages/External/Latest</c>
|
||||
/// fixed-tree node — the issue body permits this minimal surface in the first cut.
|
||||
/// Trailing nulls / spaces are trimmed before publishing so the same message
|
||||
/// round-trips with stable text. Returns <c>null</c> when the wire client cannot
|
||||
/// supply the snapshot (older transport variant).
|
||||
/// </summary>
|
||||
Task<FocasOperatorMessagesInfo?> GetOperatorMessagesAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasOperatorMessagesInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the currently-executing block text via <c>cnc_rdactpt</c> (issue #261).
|
||||
/// The call returns the active block of the running program; surfaced as
|
||||
/// <c>Program/CurrentBlock</c> Float-trimmed string. Returns <c>null</c> when the
|
||||
/// wire client cannot supply the snapshot.
|
||||
/// </summary>
|
||||
Task<FocasCurrentBlockInfo?> GetCurrentBlockAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<FocasCurrentBlockInfo?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read the per-axis decimal-place counts via <c>cnc_getfigure</c> (issue #262).
|
||||
/// Returned dictionary maps axis name (or fallback <c>"axis{n}"</c> when
|
||||
/// <c>cnc_rdaxisname</c> isn't available) to the decimal-place count the CNC
|
||||
/// reports for that axis's increment system. Cached at bootstrap by the driver +
|
||||
/// applied to position values before publishing — raw integer / 10^decimalPlaces.
|
||||
/// Returns <c>null</c> when the wire client cannot supply the snapshot (older
|
||||
/// transport variant) — the driver leaves the cache untouched and falls back to
|
||||
/// publishing raw values.
|
||||
/// </summary>
|
||||
Task<IReadOnlyDictionary<string, int>?> GetFigureScalingAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyDictionary<string, int>?>(null);
|
||||
|
||||
/// <summary>
|
||||
/// Read a CNC diagnostic value via <c>cnc_rddiag</c>. <paramref name="diagNumber"/> is
|
||||
/// the diagnostic number (validated against <see cref="FocasCapabilityMatrix.DiagnosticRange"/>
|
||||
/// by <see cref="FocasDriver.InitializeAsync"/>). <paramref name="axisOrZero"/>
|
||||
/// is 0 for whole-CNC diagnostics or the 1-based axis index for per-axis diagnostics.
|
||||
/// The shape of the returned value depends on the diagnostic — Int / Float / Bit are
|
||||
/// all possible. Returns <c>null</c> on default (transport variants that haven't yet
|
||||
/// implemented diagnostics) so the driver falls back to BadNotSupported on those nodes
|
||||
/// until the wire client is extended (issue #263).
|
||||
/// </summary>
|
||||
Task<(object? value, uint status)> ReadDiagnosticAsync(
|
||||
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<(object?, uint)>((null, FocasStatusMapper.BadNotSupported));
|
||||
|
||||
/// <summary>
|
||||
/// Discover the number of CNC paths (channels) the controller exposes via
|
||||
/// <c>cnc_rdpathnum</c>. Multi-path CNCs (lathe + sub-spindle, dual-turret,
|
||||
/// etc.) report 2..N; single-path CNCs return 1. The driver caches the result
|
||||
/// once per device after connect + uses it to validate per-tag <c>PathId</c>
|
||||
/// values (issue #264). Default returns 1 so transports that haven't extended
|
||||
/// their wire surface keep behaving as single-path.
|
||||
/// </summary>
|
||||
Task<int> GetPathCountAsync(CancellationToken cancellationToken)
|
||||
=> Task.FromResult(1);
|
||||
|
||||
/// <summary>
|
||||
/// Switch the active CNC path (channel) for subsequent reads via
|
||||
/// <c>cnc_setpath</c>. Called by the driver before every read whose
|
||||
/// <c>FocasAddress.PathId</c> differs from the path most recently set on the
|
||||
/// session — single-path devices (PathId=1 only) skip the wire call entirely.
|
||||
/// Default is a no-op so transports that haven't extended their wire surface
|
||||
/// simply read whatever path the CNC has selected (issue #264).
|
||||
/// </summary>
|
||||
Task SetPathAsync(int pathId, CancellationToken cancellationToken)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Read a contiguous range of PMC bytes in a single wire call (FOCAS
|
||||
/// <c>pmc_rdpmcrng</c> with byte data type) for the given <paramref name="letter"/>
|
||||
/// (<c>R</c>, <c>D</c>, <c>X</c>, etc.) starting at <paramref name="startByte"/> and
|
||||
/// spanning <paramref name="byteCount"/> bytes. Returned tuple has the byte buffer
|
||||
/// (length <paramref name="byteCount"/> on success) + the OPC UA status mapped through
|
||||
/// <see cref="FocasStatusMapper"/>. Used by <see cref="FocasDriver"/> to coalesce
|
||||
/// same-letter/same-path PMC reads in a batch into one round trip per range
|
||||
/// (issue #266 — see <see cref="Wire.FocasPmcCoalescer"/>).
|
||||
/// <para>
|
||||
/// Default falls back to per-byte <see cref="ReadAsync(FocasAddress, FocasDataType, CancellationToken)"/>
|
||||
/// calls so transport variants that haven't extended their wire surface still work
|
||||
/// correctly — they just won't see the round-trip reduction. The fallback short-circuits
|
||||
/// on the first non-Good status so a partial buffer isn't returned with a Good code.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
async Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
|
||||
string letter, int pathId, int startByte, int byteCount, CancellationToken cancellationToken)
|
||||
{
|
||||
if (byteCount <= 0) return (Array.Empty<byte>(), FocasStatusMapper.Good);
|
||||
var buf = new byte[byteCount];
|
||||
for (var i = 0; i < byteCount; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var addr = new FocasAddress(FocasAreaKind.Pmc, letter, startByte + i, BitIndex: null, PathId: pathId);
|
||||
var (value, status) = await ReadAsync(addr, FocasDataType.Byte, cancellationToken).ConfigureAwait(false);
|
||||
if (status != FocasStatusMapper.Good) return (null, status);
|
||||
buf[i] = value switch
|
||||
{
|
||||
sbyte s => unchecked((byte)s),
|
||||
byte b => b,
|
||||
int n => unchecked((byte)n),
|
||||
short s => unchecked((byte)s),
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
return (buf, FocasStatusMapper.Good);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the 9 fields returned by Fanuc's <c>cnc_rdcncstat</c> (ODBST). All fields
|
||||
/// are <c>short</c> per the FWLIB header — small enums whose meaning is documented in the
|
||||
/// Fanuc FOCAS reference (e.g. <c>emergency</c>: 0=released, 1=stop, 2=reset). Surfaced as
|
||||
/// <c>Int16</c> in the OPC UA address space rather than mapped enums so operators see
|
||||
/// exactly what the CNC reported.
|
||||
/// </summary>
|
||||
public sealed record FocasStatusInfo(
|
||||
short Dummy,
|
||||
short Tmmode,
|
||||
short Aut,
|
||||
short Run,
|
||||
short Motion,
|
||||
short Mstb,
|
||||
short EmergencyStop,
|
||||
short Alarm,
|
||||
short Edit);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of per-CNC production counters refreshed on the probe tick (issue #258).
|
||||
/// Sourced from <c>cnc_rdparam(6711/6712/6713)</c> for the parts counts + the cycle-time
|
||||
/// timer counter (FWLIB <c>cnc_rdtimer</c> when available). All values surfaced as
|
||||
/// <c>Int32</c> in the OPC UA address space.
|
||||
/// </summary>
|
||||
public sealed record FocasProductionInfo(
|
||||
int PartsProduced,
|
||||
int PartsRequired,
|
||||
int PartsTotal,
|
||||
int CycleTimeSeconds);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the active modal M/S/T/B codes (issue #259). G-group decoding is a
|
||||
/// deferred follow-up — the FWLIB <c>ODBMDL</c> union differs per series + group, and
|
||||
/// the issue body permits the first cut to surface only the universally-present
|
||||
/// M/S/T/B fields. <c>short</c> matches the FWLIB <c>aux_data</c> width.
|
||||
/// </summary>
|
||||
public sealed record FocasModalInfo(
|
||||
short MCode,
|
||||
short SCode,
|
||||
short TCode,
|
||||
short BCode);
|
||||
|
||||
/// <summary>
|
||||
/// MTB-specific FOCAS parameter numbers for the four operator overrides (issue #259).
|
||||
/// Defaults match Fanuc 30i — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015. A
|
||||
/// <c>null</c> entry suppresses that field's read on the wire and removes the matching
|
||||
/// node from the address space; this lets a deployment hide overrides their MTB doesn't
|
||||
/// wire up rather than always serving Bad.
|
||||
/// </summary>
|
||||
public sealed record FocasOverrideParameters(
|
||||
ushort? FeedParam,
|
||||
ushort? RapidParam,
|
||||
ushort? SpindleParam,
|
||||
ushort? JogParam)
|
||||
{
|
||||
/// <summary>Stock 30i defaults — Feed=6010, Rapid=6011, Spindle=6014, Jog=6015.</summary>
|
||||
public static FocasOverrideParameters Default { get; } = new(6010, 6011, 6014, 6015);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the four operator overrides (issue #259). Each value is a percentage
|
||||
/// surfaced as <c>Int16</c>; a value of <c>null</c> means the corresponding parameter
|
||||
/// was not configured (suppressed at <see cref="FocasOverrideParameters"/>). All four
|
||||
/// fields nullable so the driver can omit nodes whose MTB parameter is unset.
|
||||
/// </summary>
|
||||
public sealed record FocasOverrideInfo(
|
||||
short? Feed,
|
||||
short? Rapid,
|
||||
short? Spindle,
|
||||
short? Jog);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the currently selected tool number (issue #260). Sourced from
|
||||
/// <c>cnc_rdtnum</c>. The active offset index is deferred — most modern CNCs
|
||||
/// interleave tool number and offset H/D codes through different FOCAS calls
|
||||
/// (<c>cnc_rdtofs</c> against a specific slot) and the issue body permits
|
||||
/// surfacing tool number alone in the first cut. Surfaced as <c>Int16</c> in
|
||||
/// the OPC UA address space.
|
||||
/// </summary>
|
||||
public sealed record FocasToolingInfo(short CurrentTool);
|
||||
|
||||
/// <summary>
|
||||
/// One work-coordinate offset slot (G54..G59). Three axis columns are surfaced
|
||||
/// (X / Y / Z) — the issue body permits a fixed 3-axis view because lathes and
|
||||
/// mills typically don't expose extended rotational offsets via the standard
|
||||
/// <c>cnc_rdzofs</c> call. Extended <c>G54.1 Pn</c> offsets via <c>cnc_rdzofsr</c>
|
||||
/// are deferred to a follow-up PR. Values surfaced as <c>Float64</c> in microns
|
||||
/// converted to user units (the FWLIB <c>data</c> field is an integer + decimal-
|
||||
/// point count, decoded the same way <c>cnc_rdmacro</c> values are).
|
||||
/// </summary>
|
||||
public sealed record FocasWorkOffset(string Name, double X, double Y, double Z);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the six standard work-coordinate offsets (G54..G59). Refreshed on
|
||||
/// the probe tick + served from the per-device cache by reads of the
|
||||
/// <c>Offsets/{name}/{X|Y|Z}</c> fixed-tree nodes (issue #260).
|
||||
/// </summary>
|
||||
public sealed record FocasWorkOffsetsInfo(IReadOnlyList<FocasWorkOffset> Offsets);
|
||||
|
||||
/// <summary>
|
||||
/// One FANUC operator message — the <see cref="Number"/> + <see cref="Class"/>
|
||||
/// + <see cref="Text"/> tuple returned by <c>cnc_rdopmsg3</c> for a single
|
||||
/// active message slot. <see cref="Class"/> is one of <c>"OPMSG"</c> /
|
||||
/// <c>"MACRO"</c> / <c>"EXTERN"</c> / <c>"REJ-EXT"</c> per the FOCAS reference
|
||||
/// for the four message types. <see cref="Text"/> is trimmed of trailing
|
||||
/// nulls + spaces so round-trips through the OPC UA address space stay stable
|
||||
/// (issue #261).
|
||||
/// </summary>
|
||||
public sealed record FocasOperatorMessage(short Number, string Class, string Text);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of all active FANUC operator messages across the four message
|
||||
/// classes (issue #261). Surfaced under the FOCAS driver's
|
||||
/// <c>Messages/External/Latest</c> fixed-tree node — the latest non-empty
|
||||
/// message in the list is what gets published. Empty list means the CNC
|
||||
/// reported no active messages; the node publishes an empty string in that
|
||||
/// case.
|
||||
/// </summary>
|
||||
public sealed record FocasOperatorMessagesInfo(IReadOnlyList<FocasOperatorMessage> Messages);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the currently-executing program block text via
|
||||
/// <c>cnc_rdactpt</c> (issue #261). <see cref="Text"/> is trimmed of trailing
|
||||
/// nulls + spaces so the same block round-trips with stable text. Surfaced
|
||||
/// as a String node at <c>Program/CurrentBlock</c>.
|
||||
/// </summary>
|
||||
public sealed record FocasCurrentBlockInfo(string Text);
|
||||
|
||||
/// <summary>Factory for <see cref="IFocasClient"/>s. One client per configured device.</summary>
|
||||
public interface IFocasClientFactory
|
||||
{
|
||||
|
||||
152
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs
Normal file
152
src/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasPmcCoalescer.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
/// <summary>
|
||||
/// One PMC byte-level read request a caller wants to satisfy. <see cref="ByteWidth"/> is
|
||||
/// how many consecutive PMC bytes the caller's tag occupies (e.g. Bit/Byte = 1, Int16 = 2,
|
||||
/// Int32/Float32 = 4, Float64 = 8). <see cref="OriginalIndex"/> is the caller's row index
|
||||
/// in the batch — the coalescer carries it through to the planned group so the driver can
|
||||
/// fan-out the slice back to the original snapshot slot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Bit-addressed PMC tags (e.g. <c>R100.3</c>) supply their parent byte (<c>100</c>) +
|
||||
/// <c>ByteWidth = 1</c>; the slice-then-mask happens in the existing decode path, so
|
||||
/// the coalescer doesn't need to know about the bit index.
|
||||
/// </remarks>
|
||||
public sealed record PmcAddressRequest(
|
||||
string Letter,
|
||||
int PathId,
|
||||
int ByteNumber,
|
||||
int ByteWidth,
|
||||
int OriginalIndex);
|
||||
|
||||
/// <summary>
|
||||
/// One member of a coalesced PMC range — the original index + the byte offset within
|
||||
/// the planned range buffer where the member's bytes start. The caller slices
|
||||
/// <c>buffer[Offset .. Offset + ByteWidth]</c> to recover the per-tag wire-shape bytes.
|
||||
/// </summary>
|
||||
public sealed record PmcRangeMember(int OriginalIndex, int Offset, int ByteWidth);
|
||||
|
||||
/// <summary>
|
||||
/// One coalesced PMC range — a single FOCAS <c>pmc_rdpmcrng</c> wire call satisfies
|
||||
/// every member. <see cref="ByteCount"/> is bounded by <see cref="FocasPmcCoalescer.MaxRangeBytes"/>.
|
||||
/// </summary>
|
||||
public sealed record PmcRangeGroup(
|
||||
string Letter,
|
||||
int PathId,
|
||||
int StartByte,
|
||||
int ByteCount,
|
||||
IReadOnlyList<PmcRangeMember> Members);
|
||||
|
||||
/// <summary>
|
||||
/// Plans one or more coalesced PMC range reads from a flat batch of per-tag requests.
|
||||
/// Same-letter / same-path requests whose byte ranges overlap or whose gap is no larger
|
||||
/// than <see cref="MaxBridgeGap"/> are merged into a single wire call up to a
|
||||
/// <see cref="MaxRangeBytes"/> cap (issue #266).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>The cap matches the conservative ceiling Fanuc spec lists for
|
||||
/// <c>pmc_rdpmcrng</c> — most controllers accept larger ranges but 256 is the lowest
|
||||
/// common denominator across 0i / 16i / 30i firmware. Splitting on the cap is fine —
|
||||
/// each partition still saves N-1 round trips relative to per-byte reads.</para>
|
||||
///
|
||||
/// <para>The <see cref="MaxBridgeGap"/> = 16 mirrors the Modbus coalescer's bridge
|
||||
/// policy: small gaps are cheaper to over-read (one extra wire call vs. several short
|
||||
/// ones) but unbounded bridging would pull large unused regions over the wire on sparse
|
||||
/// PMC layouts.</para>
|
||||
/// </remarks>
|
||||
public static class FocasPmcCoalescer
|
||||
{
|
||||
/// <summary>Maximum bytes per coalesced range — conservative ceiling for older Fanuc firmware.</summary>
|
||||
public const int MaxRangeBytes = 256;
|
||||
|
||||
/// <summary>Maximum gap (in bytes) bridged between consecutive sub-requests within a group.</summary>
|
||||
public const int MaxBridgeGap = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Plan range reads from <paramref name="addresses"/>. Group key is
|
||||
/// <c>(Letter, PathId)</c>. Within a group, requests are sorted by start byte then
|
||||
/// greedily packed into ranges that respect <see cref="MaxRangeBytes"/> +
|
||||
/// <see cref="MaxBridgeGap"/>.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PmcRangeGroup> Plan(IEnumerable<PmcAddressRequest> addresses)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(addresses);
|
||||
|
||||
var groups = new List<PmcRangeGroup>();
|
||||
var byKey = addresses
|
||||
.Where(a => !string.IsNullOrEmpty(a.Letter) && a.ByteWidth > 0 && a.ByteNumber >= 0)
|
||||
.GroupBy(a => (Letter: a.Letter.ToUpperInvariant(), a.PathId));
|
||||
|
||||
foreach (var key in byKey)
|
||||
{
|
||||
var sorted = key.OrderBy(a => a.ByteNumber).ThenBy(a => a.OriginalIndex).ToList();
|
||||
var pending = new List<PmcAddressRequest>();
|
||||
var rangeStart = -1;
|
||||
var rangeEnd = -1;
|
||||
|
||||
void Flush()
|
||||
{
|
||||
if (pending.Count == 0) return;
|
||||
var members = pending.Select(p =>
|
||||
new PmcRangeMember(p.OriginalIndex, p.ByteNumber - rangeStart, p.ByteWidth)).ToList();
|
||||
groups.Add(new PmcRangeGroup(key.Key.Letter, key.Key.PathId, rangeStart,
|
||||
rangeEnd - rangeStart + 1, members));
|
||||
pending.Clear();
|
||||
rangeStart = -1;
|
||||
rangeEnd = -1;
|
||||
}
|
||||
|
||||
foreach (var req in sorted)
|
||||
{
|
||||
var reqStart = req.ByteNumber;
|
||||
var reqEnd = req.ByteNumber + req.ByteWidth - 1;
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
rangeStart = reqStart;
|
||||
rangeEnd = reqEnd;
|
||||
pending.Add(req);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bridge if the gap between the existing range end + this request's start is
|
||||
// within the bridge cap. Overlapping or contiguous ranges always bridge
|
||||
// (gap <= 0). The cap is enforced on the projected union: extending the range
|
||||
// must not exceed MaxRangeBytes from rangeStart.
|
||||
var gap = reqStart - rangeEnd - 1;
|
||||
var projectedEnd = Math.Max(rangeEnd, reqEnd);
|
||||
var projectedSize = projectedEnd - rangeStart + 1;
|
||||
if (gap <= MaxBridgeGap && projectedSize <= MaxRangeBytes)
|
||||
{
|
||||
rangeEnd = projectedEnd;
|
||||
pending.Add(req);
|
||||
}
|
||||
else
|
||||
{
|
||||
Flush();
|
||||
rangeStart = reqStart;
|
||||
rangeEnd = reqEnd;
|
||||
pending.Add(req);
|
||||
}
|
||||
}
|
||||
|
||||
Flush();
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The number of consecutive PMC bytes a tag of <paramref name="type"/> occupies on
|
||||
/// the wire. Used by the driver to populate <see cref="PmcAddressRequest.ByteWidth"/>
|
||||
/// before planning. Bit-addressed tags supply <c>1</c> here — the bit-extract happens
|
||||
/// in the decode path after the slice.
|
||||
/// </summary>
|
||||
public static int ByteWidth(FocasDataType type) => type switch
|
||||
{
|
||||
FocasDataType.Bit or FocasDataType.Byte => 1,
|
||||
FocasDataType.Int16 => 2,
|
||||
FocasDataType.Int32 or FocasDataType.Float32 => 4,
|
||||
FocasDataType.Float64 => 8,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
|
||||
|
||||
/// <summary>
|
||||
/// Per-driver counters surfaced via <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
|
||||
/// for the <c>driver-diagnostics</c> RPC (task #276). Hot-path increments use
|
||||
/// <see cref="Interlocked"/> so they're lock-free; the read path snapshots into a
|
||||
/// <see cref="IReadOnlyDictionary{TKey, TValue}"/> keyed by stable counter names.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The counters are operational metrics, not config — they reset to zero when the
|
||||
/// driver instance is recreated (Reinitialize tear-down + rebuild) and there is no
|
||||
/// persistence across process restarts. NotificationsPerSecond is a simple decay-EWMA
|
||||
/// so a quiet subscription doesn't latch the value at the last burst rate.
|
||||
/// </remarks>
|
||||
internal sealed class OpcUaClientDiagnostics
|
||||
{
|
||||
// ---- Hot-path counters (Interlocked) ----
|
||||
|
||||
private long _publishRequestCount;
|
||||
private long _notificationCount;
|
||||
private long _missingPublishRequestCount;
|
||||
private long _droppedNotificationCount;
|
||||
private long _sessionResetCount;
|
||||
|
||||
// ---- EWMA state for NotificationsPerSecond ----
|
||||
//
|
||||
// Use ticks (long) for the timestamp so we can swap atomically. The rate is a double
|
||||
// updated under a tight lock — the EWMA arithmetic (load, blend, store) isn't naturally
|
||||
// atomic on doubles, and the spinlock is held only for arithmetic so contention is
|
||||
// bounded. A subscription firing at 10 kHz with one driver instance is dominated by
|
||||
// the SDK's notification path, not this lock.
|
||||
private readonly object _ewmaLock = new();
|
||||
private double _notificationsPerSecond;
|
||||
private long _lastNotificationTicks;
|
||||
|
||||
/// <summary>Half-life ~5 seconds — recent activity dominates but a paused subscription decays toward zero.</summary>
|
||||
private static readonly TimeSpan EwmaHalfLife = TimeSpan.FromSeconds(5);
|
||||
|
||||
// ---- Reconnect state (lock-free, single-writer in OnReconnectComplete) ----
|
||||
private long _lastReconnectUtcTicks;
|
||||
|
||||
public long PublishRequestCount => Interlocked.Read(ref _publishRequestCount);
|
||||
public long NotificationCount => Interlocked.Read(ref _notificationCount);
|
||||
public long MissingPublishRequestCount => Interlocked.Read(ref _missingPublishRequestCount);
|
||||
public long DroppedNotificationCount => Interlocked.Read(ref _droppedNotificationCount);
|
||||
public long SessionResetCount => Interlocked.Read(ref _sessionResetCount);
|
||||
|
||||
public DateTime? LastReconnectUtc
|
||||
{
|
||||
get
|
||||
{
|
||||
var ticks = Interlocked.Read(ref _lastReconnectUtcTicks);
|
||||
return ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
|
||||
public double NotificationsPerSecond
|
||||
{
|
||||
get { lock (_ewmaLock) return _notificationsPerSecond; }
|
||||
}
|
||||
|
||||
public void IncrementPublishRequest() => Interlocked.Increment(ref _publishRequestCount);
|
||||
|
||||
public void IncrementMissingPublishRequest() => Interlocked.Increment(ref _missingPublishRequestCount);
|
||||
|
||||
public void IncrementDroppedNotification() => Interlocked.Increment(ref _droppedNotificationCount);
|
||||
|
||||
/// <summary>Records one delivered notification (any monitored item) + folds the inter-arrival into the EWMA rate.</summary>
|
||||
public void RecordNotification() => RecordNotification(DateTime.UtcNow);
|
||||
|
||||
internal void RecordNotification(DateTime nowUtc)
|
||||
{
|
||||
Interlocked.Increment(ref _notificationCount);
|
||||
|
||||
// EWMA over instantaneous rate. instRate = 1 / dt (events per second since last sample).
|
||||
// Decay factor a = 2^(-dt/halfLife) puts a five-second window on the smoothing — recent
|
||||
// bursts win, idle periods bleed back to zero.
|
||||
var nowTicks = nowUtc.Ticks;
|
||||
lock (_ewmaLock)
|
||||
{
|
||||
if (_lastNotificationTicks == 0)
|
||||
{
|
||||
_lastNotificationTicks = nowTicks;
|
||||
// First sample: seed at 0 — we don't know the prior rate. The next sample
|
||||
// produces a real instRate.
|
||||
return;
|
||||
}
|
||||
var dtTicks = nowTicks - _lastNotificationTicks;
|
||||
if (dtTicks <= 0)
|
||||
{
|
||||
// Same-tick collisions on bursts: treat as no time elapsed for rate purposes
|
||||
// (count was already incremented above) so we don't divide by zero or feed
|
||||
// an absurd instRate spike.
|
||||
return;
|
||||
}
|
||||
var dtSeconds = (double)dtTicks / TimeSpan.TicksPerSecond;
|
||||
var instRate = 1.0 / dtSeconds;
|
||||
var alpha = System.Math.Pow(0.5, dtSeconds / EwmaHalfLife.TotalSeconds);
|
||||
_notificationsPerSecond = (alpha * _notificationsPerSecond) + ((1.0 - alpha) * instRate);
|
||||
_lastNotificationTicks = nowTicks;
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordSessionReset(DateTime nowUtc)
|
||||
{
|
||||
Interlocked.Increment(ref _sessionResetCount);
|
||||
Interlocked.Exchange(ref _lastReconnectUtcTicks, nowUtc.Ticks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the counters into the dictionary shape <see cref="Core.Abstractions.DriverHealth.Diagnostics"/>
|
||||
/// surfaces. Numeric-only (so the RPC can render generically); LastReconnectUtc is
|
||||
/// emitted as ticks to keep the value type uniform.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, double> Snapshot()
|
||||
{
|
||||
var dict = new Dictionary<string, double>(7, System.StringComparer.Ordinal)
|
||||
{
|
||||
["PublishRequestCount"] = PublishRequestCount,
|
||||
["NotificationCount"] = NotificationCount,
|
||||
["NotificationsPerSecond"] = NotificationsPerSecond,
|
||||
["MissingPublishRequestCount"] = MissingPublishRequestCount,
|
||||
["DroppedNotificationCount"] = DroppedNotificationCount,
|
||||
["SessionResetCount"] = SessionResetCount,
|
||||
};
|
||||
var last = LastReconnectUtc;
|
||||
if (last is not null)
|
||||
dict["LastReconnectUtcTicks"] = last.Value.Ticks;
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,6 +39,34 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// </summary>
|
||||
public TimeSpan PerEndpointConnectTimeout { get; init; } = TimeSpan.FromSeconds(3);
|
||||
|
||||
/// <summary>
|
||||
/// Optional discovery URL pointing at a Local Discovery Server (LDS) or a server's
|
||||
/// own discovery endpoint. When set, the driver runs <c>FindServers</c> +
|
||||
/// <c>GetEndpoints</c> against this URL during <see cref="OpcUaClientDriver.InitializeAsync"/>
|
||||
/// and prepends the discovered endpoint URLs to the failover candidate list. When
|
||||
/// <see cref="EndpointUrls"/> is empty (and only <see cref="EndpointUrl"/> is set as
|
||||
/// a fallback), the discovered URLs replace the candidate list entirely so a
|
||||
/// discovery-driven deployment can be configured without specifying any endpoints
|
||||
/// up front. Discovery failures are non-fatal — the driver logs and falls back to the
|
||||
/// statically configured candidates.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>FindServers requires SecurityMode=None on the discovery channel</b> per the
|
||||
/// OPC UA spec — discovery is unauthenticated even when the data channel uses
|
||||
/// <c>Sign</c> or <c>SignAndEncrypt</c>. The driver opens the discovery channel
|
||||
/// unsecured regardless of <see cref="SecurityMode"/>; only the resulting data
|
||||
/// session is bound to the configured policy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Endpoints returned by discovery are filtered to those matching
|
||||
/// <see cref="SecurityPolicy"/> + <see cref="SecurityMode"/> before being added to
|
||||
/// the candidate list, so a discovery sweep against a multi-policy server only
|
||||
/// surfaces endpoints the driver could actually connect to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string? DiscoveryUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Security policy to require when selecting an endpoint. Either a
|
||||
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
|
||||
@@ -134,8 +162,198 @@ public sealed class OpcUaClientDriverOptions
|
||||
/// browse forever.
|
||||
/// </summary>
|
||||
public int MaxBrowseDepth { get; init; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Per-subscription tuning knobs applied when the driver creates data + alarm
|
||||
/// subscriptions on the upstream session. Defaults preserve the previous hard-coded
|
||||
/// values so existing deployments see no behaviour change.
|
||||
/// </summary>
|
||||
public OpcUaSubscriptionDefaults Subscriptions { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Server-certificate validation knobs applied during the
|
||||
/// <c>CertificateValidator.CertificateValidation</c> callback. Surfaces explicit
|
||||
/// handling for revoked certs (always rejected, never auto-accepted), unknown
|
||||
/// revocation status (rejected only when <see cref="OpcUaCertificateValidationOptions.RejectUnknownRevocationStatus"/>
|
||||
/// is set), SHA-1 signature rejection, and minimum RSA key size. Defaults preserve
|
||||
/// existing behaviour wherever possible — the one tightening is
|
||||
/// <see cref="OpcUaCertificateValidationOptions.RejectSHA1SignedCertificates"/>=true
|
||||
/// since SHA-1 is spec-deprecated for OPC UA.
|
||||
/// </summary>
|
||||
public OpcUaCertificateValidationOptions CertificateValidation { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Curation rules applied to the upstream address space during
|
||||
/// <c>DiscoverAsync</c>. Lets operators trim the mirrored tree to the subset their
|
||||
/// downstream clients actually need, rename namespace URIs so the local-side metadata
|
||||
/// stays consistent across upstream-server swaps, and override the default
|
||||
/// <c>"Remote"</c> root folder name. Defaults are empty / null which preserves the
|
||||
/// pre-curation behaviour exactly — empty include = include all.
|
||||
/// </summary>
|
||||
public OpcUaClientCurationOptions Curation { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When <c>true</c>, <c>DiscoverAsync</c> runs an additional pass that walks the upstream
|
||||
/// <c>TypesFolder</c> (<c>i=86</c>) — ObjectTypes (<c>i=88</c>), VariableTypes
|
||||
/// (<c>i=89</c>), DataTypes (<c>i=90</c>), ReferenceTypes (<c>i=91</c>) — and projects the
|
||||
/// discovered type-definition nodes into the local address space via
|
||||
/// <c>IAddressSpaceBuilder.RegisterTypeNode</c>. Default <c>false</c> — opt-in so
|
||||
/// existing deployments don't suddenly see a flood of type nodes after upgrade. Enable
|
||||
/// when downstream clients need the upstream type system to render structured values or
|
||||
/// decode custom event fields.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The type-mirror pass uses <c>Session.FetchTypeTreeAsync</c> on each of the four
|
||||
/// root type nodes so the SDK's local TypeTree cache is populated efficiently (one
|
||||
/// batched browse per root rather than per-node round trips). This PR ships the
|
||||
/// <i>structural</i> mirror only — every type node is registered with its identity,
|
||||
/// super-type chain, and IsAbstract flag, but structured-type binary encodings are
|
||||
/// NOT primed. (The OPCFoundation SDK removed
|
||||
/// <c>ISession.LoadDataTypeSystem(NodeId, CancellationToken)</c> from the public
|
||||
/// surface in 1.5.378+; loading binary encodings now requires per-node walks of
|
||||
/// <c>HasEncoding</c> + dictionary nodes which is tracked as a follow-up.) Clients
|
||||
/// that need structured-type decoding can still consume
|
||||
/// <c>Variant<ExtensionObject></c> on the wire.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="OpcUaClientCurationOptions.IncludePaths"/> +
|
||||
/// <see cref="OpcUaClientCurationOptions.ExcludePaths"/> still apply to the type
|
||||
/// walk; paths are slash-joined under their root (e.g.
|
||||
/// <c>"ObjectTypes/BaseObjectType/SomeType"</c>). Most operators want all types so
|
||||
/// empty include = include all is the right default.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool MirrorTypeDefinitions { get; init; } = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selective import + namespace remap rules for the OPC UA Client driver. Pure local
|
||||
/// filtering inside <c>BrowseRecursiveAsync</c> + <c>EnrichAndRegisterVariablesAsync</c>;
|
||||
/// no new SDK calls.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Glob semantics</b>: patterns are matched against the slash-joined BrowseName
|
||||
/// segments accumulated during the browse pass (e.g. <c>"Server/Diagnostics/SessionsDiagnosticsArray"</c>).
|
||||
/// Two wildcards are supported — <c>*</c> matches any sequence of characters
|
||||
/// (including empty / slashes) and <c>?</c> matches exactly one character. No
|
||||
/// character classes, no <c>**</c>, no escapes — keep the surface tight so the doc
|
||||
/// + behaviour stay simple.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Empty <see cref="IncludePaths"/> = include all (existing behaviour).
|
||||
/// <see cref="ExcludePaths"/> wins over <see cref="IncludePaths"/> when both match.
|
||||
/// Folders pruned by the rules are skipped wholesale — their descendants don't get
|
||||
/// browsed, which keeps the wire cost down on large servers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="IncludePaths">
|
||||
/// Glob patterns matched against the BrowsePath segment list. Empty = include all
|
||||
/// (default — preserves pre-curation behaviour).
|
||||
/// </param>
|
||||
/// <param name="ExcludePaths">
|
||||
/// Glob patterns matched against the BrowsePath segment list. Wins over
|
||||
/// <see cref="IncludePaths"/> — useful for "include everything under <c>Plant/*</c>
|
||||
/// except <c>Plant/Diagnostics</c>" rules.
|
||||
/// </param>
|
||||
/// <param name="NamespaceRemap">
|
||||
/// Upstream-namespace-URI → local-namespace-URI translation table applied to the
|
||||
/// <c>FullName</c> field of <c>DriverAttributeInfo</c> when registering variables.
|
||||
/// The driver's stored <c>FullName</c> swaps the prefix before persisting so downstream
|
||||
/// clients see the remapped URI. Lookup is case-sensitive — match the upstream URI
|
||||
/// exactly. Defaults to empty (no remap).
|
||||
/// </param>
|
||||
/// <param name="RootAlias">
|
||||
/// Replaces the default <c>"Remote"</c> folder name at the top of the mirrored tree.
|
||||
/// Useful when multiple OPC UA Client drivers are aggregated and operators need to
|
||||
/// distinguish them in the local browse tree. Default <c>null</c> = use <c>"Remote"</c>.
|
||||
/// </param>
|
||||
public sealed record OpcUaClientCurationOptions(
|
||||
IReadOnlyList<string>? IncludePaths = null,
|
||||
IReadOnlyList<string>? ExcludePaths = null,
|
||||
IReadOnlyDictionary<string, string>? NamespaceRemap = null,
|
||||
string? RootAlias = null);
|
||||
|
||||
/// <summary>
|
||||
/// Knobs governing the server-certificate validation callback. Plumbed onto
|
||||
/// <see cref="OpcUaClientDriverOptions.CertificateValidation"/> rather than the top-level
|
||||
/// options to keep cert-related config grouped together.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>CRL discovery:</b> the OPC UA SDK reads CRL files automatically from the
|
||||
/// <c>crl/</c> sub-directory of each cert store (own, trusted, issuers). Drop the
|
||||
/// issuer's <c>.crl</c> in that folder and the SDK picks it up — no driver-side wiring
|
||||
/// required. When the directory is absent or empty, the SDK reports
|
||||
/// <c>BadCertificateRevocationUnknown</c>, which this driver gates with
|
||||
/// <see cref="RejectUnknownRevocationStatus"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="RejectSHA1SignedCertificates">
|
||||
/// Reject server certificates whose signature uses SHA-1. Default <c>true</c> — SHA-1 was
|
||||
/// deprecated by the OPC UA spec and is treated as a hard fail in production. Flip to
|
||||
/// <c>false</c> only for short-term interop with legacy controllers.
|
||||
/// </param>
|
||||
/// <param name="RejectUnknownRevocationStatus">
|
||||
/// When the SDK can't determine revocation status (no CRL present, or stale CRL),
|
||||
/// reject the cert if <c>true</c>; allow if <c>false</c>. Default <c>false</c> — many
|
||||
/// plant deployments don't run CRL infrastructure, and a hard-fail default would break
|
||||
/// them on first connection. Set <c>true</c> in environments with a managed PKI.
|
||||
/// </param>
|
||||
/// <param name="MinimumCertificateKeySize">
|
||||
/// Minimum RSA key size (bits) accepted. Certs with shorter keys are rejected. Default
|
||||
/// <c>2048</c> matches the current OPC UA spec floor; raise to 3072 or 4096 for stricter
|
||||
/// deployments. Non-RSA keys (ECC) bypass this check.
|
||||
/// </param>
|
||||
public sealed record OpcUaCertificateValidationOptions(
|
||||
bool RejectSHA1SignedCertificates = true,
|
||||
bool RejectUnknownRevocationStatus = false,
|
||||
int MinimumCertificateKeySize = 2048);
|
||||
|
||||
/// <summary>
|
||||
/// Tuning surface for OPC UA subscriptions created by <see cref="OpcUaClientDriver"/>.
|
||||
/// Lifted from the per-call hard-coded literals so operators can tune publish cadence,
|
||||
/// keep-alive ratio, and alarm-vs-data prioritisation without recompiling the driver.
|
||||
/// Defaults match the original hard-coded values (KeepAlive=10, Lifetime=1000,
|
||||
/// MaxNotifications=0 unlimited, Priority=0, MinPublishingInterval=50ms).
|
||||
/// </summary>
|
||||
/// <param name="KeepAliveCount">
|
||||
/// Number of consecutive empty publish cycles before the server sends a keep-alive
|
||||
/// response. Default 10 — high enough to suppress idle traffic, low enough that the
|
||||
/// client notices a stalled subscription within ~5x the publish interval.
|
||||
/// </param>
|
||||
/// <param name="LifetimeCount">
|
||||
/// Number of consecutive missed publish responses before the server tears down the
|
||||
/// subscription. Must be ≥3×<see cref="KeepAliveCount"/> per OPC UA spec; default 1000
|
||||
/// gives ~100 keep-alives of slack which is conservative on flaky networks.
|
||||
/// </param>
|
||||
/// <param name="MaxNotificationsPerPublish">
|
||||
/// Cap on notifications returned per publish response. <c>0</c> = unlimited (the OPC UA
|
||||
/// spec sentinel). Lower this to bound publish-message size on bursty servers.
|
||||
/// </param>
|
||||
/// <param name="Priority">
|
||||
/// Subscription priority for data subscriptions (0..255). Higher = scheduled ahead of
|
||||
/// lower. Default 0 matches the SDK's default for ordinary tag subscriptions.
|
||||
/// </param>
|
||||
/// <param name="MinPublishingIntervalMs">
|
||||
/// Floor (ms) applied to <c>publishingInterval</c> requests. Sub-floor values are
|
||||
/// clamped up so wire-side negotiations don't waste round-trips on intervals the server
|
||||
/// will only round up anyway. Default 50ms.
|
||||
/// </param>
|
||||
/// <param name="AlarmsPriority">
|
||||
/// Subscription priority for the alarm subscription (0..255). Higher than
|
||||
/// <see cref="Priority"/> by default (1 vs 0) so alarm publishes aren't starved during
|
||||
/// data-tag bursts.
|
||||
/// </param>
|
||||
public sealed record OpcUaSubscriptionDefaults(
|
||||
int KeepAliveCount = 10,
|
||||
uint LifetimeCount = 1000,
|
||||
uint MaxNotificationsPerPublish = 0,
|
||||
byte Priority = 0,
|
||||
int MinPublishingIntervalMs = 50,
|
||||
byte AlarmsPriority = 1);
|
||||
|
||||
/// <summary>OPC UA message security mode.</summary>
|
||||
public enum OpcUaSecurityMode
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
@@ -26,10 +28,12 @@ public enum S7Size
|
||||
Byte, // B
|
||||
Word, // W — 16-bit
|
||||
DWord, // D — 32-bit
|
||||
LWord, // LD / DBL — 64-bit (LInt/ULInt/LReal). S7.Net has no native size suffix; the
|
||||
// driver issues an 8-byte ReadBytes and converts big-endian in-process.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse"/>.
|
||||
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse(string)"/>.
|
||||
/// </summary>
|
||||
/// <param name="Area">Memory area (DB, M, I, Q, T, C).</param>
|
||||
/// <param name="DbNumber">Data block number; only meaningful when <paramref name="Area"/> is <see cref="S7Area.DataBlock"/>.</param>
|
||||
@@ -48,9 +52,12 @@ public readonly record struct S7ParsedAddress(
|
||||
/// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>DB{n}.DB{X|B|W|D}{offset}[.bit]</c> — e.g. <c>DB1.DBX0.0</c>, <c>DB1.DBW0</c>, <c>DB1.DBD4</c></item>
|
||||
/// <item><c>DB{n}.{DBLD|DBL}{offset}</c> — 64-bit (LInt / ULInt / LReal) e.g. <c>DB1.DBLD0</c>, <c>DB1.DBL8</c></item>
|
||||
/// <item><c>M{B|W|D}{offset}</c> or <c>M{offset}.{bit}</c> — e.g. <c>MB0</c>, <c>MW0</c>, <c>MD4</c>, <c>M0.0</c></item>
|
||||
/// <item><c>M{LD}{offset}</c> — 64-bit Merker, e.g. <c>MLD0</c></item>
|
||||
/// <item><c>I{B|W|D}{offset}</c> or <c>I{offset}.{bit}</c> — e.g. <c>IB0</c>, <c>IW0</c>, <c>ID0</c>, <c>I0.0</c></item>
|
||||
/// <item><c>Q{B|W|D}{offset}</c> or <c>Q{offset}.{bit}</c> — e.g. <c>QB0</c>, <c>QW0</c>, <c>QD0</c>, <c>Q0.0</c></item>
|
||||
/// <item><c>I{LD}{offset}</c> / <c>Q{LD}{offset}</c> — 64-bit Input/Output, e.g. <c>ILD0</c>, <c>QLD0</c></item>
|
||||
/// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</c></item>
|
||||
/// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
|
||||
/// </list>
|
||||
@@ -69,7 +76,29 @@ public static class S7AddressParser
|
||||
/// the offending input echoed in the message so operators can correlate to the tag
|
||||
/// config that produced the fault.
|
||||
/// </summary>
|
||||
public static S7ParsedAddress Parse(string address)
|
||||
/// <remarks>
|
||||
/// The CPU-agnostic overload rejects the <c>V</c> area letter; <c>V</c> is only
|
||||
/// meaningful on S7-200 / S7-200 Smart / LOGO! where it maps to a fixed DB number
|
||||
/// (DB1 by convention) — call <see cref="Parse(string, S7NetCpuType?)"/> with the
|
||||
/// device's CPU family for V-memory tags.
|
||||
/// </remarks>
|
||||
public static S7ParsedAddress Parse(string address) => Parse(address, cpuType: null);
|
||||
|
||||
/// <summary>
|
||||
/// Parse an S7 address with knowledge of the device's CPU family. Required for the
|
||||
/// <c>V</c> area letter (S7-200 / S7-200 Smart / LOGO! V-memory), which maps to
|
||||
/// DataBlock DB1 on those families. On S7-300 / S7-400 / S7-1200 / S7-1500 the
|
||||
/// <c>V</c> letter is rejected because it has no equivalent — those families use
|
||||
/// explicit <c>DB{n}.DB...</c> addressing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// LOGO! firmware bands map V-memory to different underlying DB numbers in some
|
||||
/// 0BA editions; the driver currently uses DB1 (the most common LOGO! 8 / 0BA8
|
||||
/// mapping). If a future site ships a firmware band where VM lives in a different
|
||||
/// DB, the mapping table in <see cref="VMemoryDbNumberFor"/> is the single point
|
||||
/// to extend. Live LOGO! testing is out of scope for the initial PR.
|
||||
/// </remarks>
|
||||
public static S7ParsedAddress Parse(string address, S7NetCpuType? cpuType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
throw new FormatException("S7 address must not be empty");
|
||||
@@ -92,21 +121,28 @@ public static class S7AddressParser
|
||||
case 'Q': return ParseMIQ(S7Area.Output, rest, address);
|
||||
case 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address);
|
||||
case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address);
|
||||
case 'V': return ParseV(rest, address, cpuType);
|
||||
default:
|
||||
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C)");
|
||||
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C/V)");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try-parse variant for callers that can't afford an exception on bad input (e.g.
|
||||
/// config validation pages in the Admin UI). Returns <c>false</c> for any input that
|
||||
/// would throw from <see cref="Parse"/>.
|
||||
/// would throw from <see cref="Parse(string)"/>.
|
||||
/// </summary>
|
||||
public static bool TryParse(string address, out S7ParsedAddress result)
|
||||
=> TryParse(address, cpuType: null, out result);
|
||||
|
||||
/// <summary>
|
||||
/// Try-parse variant that accepts a CPU family for V-memory addressing.
|
||||
/// </summary>
|
||||
public static bool TryParse(string address, S7NetCpuType? cpuType, out S7ParsedAddress result)
|
||||
{
|
||||
try
|
||||
{
|
||||
result = Parse(address);
|
||||
result = Parse(address, cpuType);
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
@@ -130,18 +166,36 @@ public static class S7AddressParser
|
||||
throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
|
||||
|
||||
if (!tail.StartsWith("DB") || tail.Length < 4)
|
||||
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D}}");
|
||||
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D|LD|L}}");
|
||||
|
||||
var sizeChar = tail[2];
|
||||
var offsetStart = 3;
|
||||
var size = sizeChar switch
|
||||
// 64-bit suffixes are two-letter (LD or DBL-as-prefix). Detect them up front so the
|
||||
// single-char switch below stays readable. "DBLD" is the symmetric extension of
|
||||
// DBX/DBB/DBW/DBD; "DBL" is the shorter Siemens "long" alias accepted as an alternate.
|
||||
S7Size size;
|
||||
int offsetStart;
|
||||
if (tail.Length >= 5 && tail[2] == 'L' && tail[3] == 'D')
|
||||
{
|
||||
'X' => S7Size.Bit,
|
||||
'B' => S7Size.Byte,
|
||||
'W' => S7Size.Word,
|
||||
'D' => S7Size.DWord,
|
||||
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"),
|
||||
};
|
||||
size = S7Size.LWord;
|
||||
offsetStart = 4;
|
||||
}
|
||||
else if (tail.Length >= 4 && tail[2] == 'L')
|
||||
{
|
||||
size = S7Size.LWord;
|
||||
offsetStart = 3;
|
||||
}
|
||||
else
|
||||
{
|
||||
var sizeChar = tail[2];
|
||||
offsetStart = 3;
|
||||
size = sizeChar switch
|
||||
{
|
||||
'X' => S7Size.Bit,
|
||||
'B' => S7Size.Byte,
|
||||
'W' => S7Size.Word,
|
||||
'D' => S7Size.DWord,
|
||||
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D/LD/L"),
|
||||
};
|
||||
}
|
||||
|
||||
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s);
|
||||
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
|
||||
@@ -156,23 +210,73 @@ public static class S7AddressParser
|
||||
var first = rest[0];
|
||||
S7Size size;
|
||||
int offsetStart;
|
||||
switch (first)
|
||||
// Two-char "LD" prefix (8-byte LWord) checked first so it doesn't get swallowed by
|
||||
// the single-letter cases below.
|
||||
if (rest.Length >= 2 && first == 'L' && rest[1] == 'D')
|
||||
{
|
||||
case 'B': size = S7Size.Byte; offsetStart = 1; break;
|
||||
case 'W': size = S7Size.Word; offsetStart = 1; break;
|
||||
case 'D': size = S7Size.DWord; offsetStart = 1; break;
|
||||
default:
|
||||
// No size prefix => bit-level address requires explicit .bit. Size stays Bit;
|
||||
// ParseOffsetAndOptionalBit will demand the dot.
|
||||
size = S7Size.Bit;
|
||||
offsetStart = 0;
|
||||
break;
|
||||
size = S7Size.LWord;
|
||||
offsetStart = 2;
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (first)
|
||||
{
|
||||
case 'B': size = S7Size.Byte; offsetStart = 1; break;
|
||||
case 'W': size = S7Size.Word; offsetStart = 1; break;
|
||||
case 'D': size = S7Size.DWord; offsetStart = 1; break;
|
||||
default:
|
||||
// No size prefix => bit-level address requires explicit .bit. Size stays Bit;
|
||||
// ParseOffsetAndOptionalBit will demand the dot.
|
||||
size = S7Size.Bit;
|
||||
offsetStart = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original);
|
||||
return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a <c>V</c>-area address (S7-200 / S7-200 Smart / LOGO! V-memory). Same width
|
||||
/// suffixes as M/I/Q (<c>VB</c>, <c>VW</c>, <c>VD</c>, <c>V0.0</c>) but rewritten as
|
||||
/// a DataBlock access so the rest of the driver — which speaks S7.Net's DB-centric
|
||||
/// API — needs no special-casing downstream.
|
||||
/// </summary>
|
||||
private static S7ParsedAddress ParseV(string rest, string original, S7NetCpuType? cpuType)
|
||||
{
|
||||
var dbNumber = VMemoryDbNumberFor(cpuType, original);
|
||||
// Reuse the M/I/Q grammar — V's size suffixes are identical (B/W/D/LD or .bit).
|
||||
var parsed = ParseMIQ(S7Area.Memory, rest, original);
|
||||
return parsed with { Area = S7Area.DataBlock, DbNumber = dbNumber };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a CPU family to the underlying DB number that backs V-memory. Returns DB1
|
||||
/// for S7-200, S7-200 Smart, and LOGO! 0BA8 (the only LOGO! the S7.Net <c>CpuType</c>
|
||||
/// enum surfaces). Throws for families that have no V-area concept.
|
||||
/// </summary>
|
||||
private static int VMemoryDbNumberFor(S7NetCpuType? cpuType, string original)
|
||||
{
|
||||
if (cpuType is null)
|
||||
throw new FormatException(
|
||||
$"S7 V-memory address '{original}' requires a CPU family (S7-200 / S7-200 Smart / LOGO!) — " +
|
||||
"the CPU-agnostic Parse overload cannot resolve V-memory to a DB number");
|
||||
|
||||
return cpuType.Value switch
|
||||
{
|
||||
S7NetCpuType.S7200 => 1,
|
||||
S7NetCpuType.S7200Smart => 1,
|
||||
// LOGO! 8 / 0BA8 firmware bands typically expose VM as DB1 over S7comm. Older
|
||||
// 0BA editions can differ; the mapping is centralised here for easy extension
|
||||
// once a site provides a non-DB1 firmware band to test against.
|
||||
S7NetCpuType.Logo0BA8 => 1,
|
||||
_ => throw new FormatException(
|
||||
$"S7 V-memory address '{original}' is only valid on S7-200 / S7-200 Smart / LOGO! " +
|
||||
$"(got CpuType={cpuType.Value}); use explicit DB{{n}}.DB... addressing on this family"),
|
||||
};
|
||||
}
|
||||
|
||||
private static S7ParsedAddress ParseTimerOrCounter(S7Area area, string rest, string original)
|
||||
{
|
||||
if (rest.Length == 0)
|
||||
|
||||
241
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7BlockCoalescingPlanner.cs
Normal file
241
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7BlockCoalescingPlanner.cs
Normal file
@@ -0,0 +1,241 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Block-read coalescing planner for the S7 driver (PR-S7-B2). Where the
|
||||
/// <see cref="S7ReadPacker"/> coalesces N scalar tags into ⌈N/19⌉
|
||||
/// <c>Plc.ReadMultipleVarsAsync</c> PDUs, this planner takes one further pass:
|
||||
/// it groups same-area, same-DB tags by contiguous byte range and folds them
|
||||
/// into a single <c>Plc.ReadBytesAsync</c> covering the merged span. The
|
||||
/// response is sliced client-side per tag so the per-tag decode path is
|
||||
/// unchanged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why coalesce</b>: Reading <c>DB1.DBW0</c> + <c>DB1.DBW2</c> +
|
||||
/// <c>DB1.DBW4</c> as three multi-var items still uses three slots in a
|
||||
/// single PDU; coalescing into one 6-byte byte-range read drops the per-item
|
||||
/// framing entirely and makes the request fit in fewer (sometimes zero
|
||||
/// additional) PDUs. On a typical contiguous DB the wire-level reduction is
|
||||
/// 50:1 for 50 contiguous DBWs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Gap-merge threshold</b>: The planner merges adjacent tag ranges when
|
||||
/// the gap between them is at most the <c>gapMergeBytes</c> argument to
|
||||
/// <see cref="Plan"/>. The default <see cref="DefaultGapMergeBytes"/> is
|
||||
/// 16 bytes — over-fetching 16 bytes is cheaper than one extra PDU
|
||||
/// (240-byte default PDU envelope, ~18 bytes per request frame). Operators
|
||||
/// can tune the threshold per driver instance via
|
||||
/// <see cref="S7DriverOptions.BlockCoalescingGapBytes"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Opaque-size opt-out</b>: STRING / WSTRING / CHAR / WCHAR and DTL /
|
||||
/// DT / S5TIME / TIME / TOD / DATE-as-DateTime tags carry a header (or
|
||||
/// have a per-tag width that varies with <c>StringLength</c>) and are
|
||||
/// flagged <c>OpaqueSize=true</c>. The planner emits these as standalone
|
||||
/// single-tag ranges and never merges them into a sibling block — the
|
||||
/// per-tag decode path needs an exact byte slice and a wrong slice from
|
||||
/// a coalesced read would silently corrupt every neighbour.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Order-preserving</b>: Each <see cref="BlockReadRange"/> carries a list
|
||||
/// of <see cref="TagSlice"/> values pointing back at the original
|
||||
/// caller-index. The driver's <c>ReadAsync</c> uses the index to write the
|
||||
/// decoded value into the correct slot of the result array, so caller
|
||||
/// ordering of the input <c>fullReferences</c> is preserved across the
|
||||
/// coalescing step.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class S7BlockCoalescingPlanner
|
||||
{
|
||||
/// <summary>Default gap-merge threshold in bytes.</summary>
|
||||
internal const int DefaultGapMergeBytes = 16;
|
||||
|
||||
/// <summary>
|
||||
/// One coalesced byte-range request. The driver issues a single
|
||||
/// <c>Plc.ReadBytesAsync</c> covering <see cref="StartByte"/>..
|
||||
/// <see cref="StartByte"/>+<see cref="ByteCount"/>; each entry in
|
||||
/// <see cref="Tags"/> carries the offset within the response buffer to
|
||||
/// slice for that tag.
|
||||
/// </summary>
|
||||
internal sealed record BlockReadRange(
|
||||
S7Area Area,
|
||||
int DbNumber,
|
||||
int StartByte,
|
||||
int ByteCount,
|
||||
IReadOnlyList<TagSlice> Tags);
|
||||
|
||||
/// <summary>
|
||||
/// One tag's slot inside a <see cref="BlockReadRange"/>. <see cref="OffsetInBlock"/>
|
||||
/// is the byte offset within the coalesced buffer; <see cref="ByteCount"/> is the
|
||||
/// per-tag width that the slice covers.
|
||||
/// </summary>
|
||||
/// <param name="CallerIndex">Original index in the caller's <c>fullReferences</c> list.</param>
|
||||
/// <param name="OffsetInBlock">Byte offset into <see cref="BlockReadRange"/>'s buffer.</param>
|
||||
/// <param name="ByteCount">Bytes the tag claims from the buffer.</param>
|
||||
internal sealed record TagSlice(int CallerIndex, int OffsetInBlock, int ByteCount);
|
||||
|
||||
/// <summary>
|
||||
/// Input row. Captures everything the planner needs to make a coalescing
|
||||
/// decision without needing the full <see cref="S7TagDefinition"/> graph.
|
||||
/// </summary>
|
||||
/// <param name="CallerIndex">Caller-supplied stable index used to thread the decoded value back.</param>
|
||||
/// <param name="Area">Memory area; M and DB never merge into the same range.</param>
|
||||
/// <param name="DbNumber">DB number when <see cref="Area"/> is DataBlock; 0 otherwise.</param>
|
||||
/// <param name="StartByte">Byte offset in the area where the tag's storage begins.</param>
|
||||
/// <param name="ByteCount">On-wire byte width of the tag.</param>
|
||||
/// <param name="OpaqueSize">
|
||||
/// True for tags whose effective decode width is variable / header-prefixed
|
||||
/// (STRING/WSTRING/CHAR/WCHAR and structured timestamps DTL/DT/etc.) so the
|
||||
/// planner skips them — they emit standalone reads and never merge with
|
||||
/// neighbours.
|
||||
/// </param>
|
||||
internal sealed record TagSpec(
|
||||
int CallerIndex,
|
||||
S7Area Area,
|
||||
int DbNumber,
|
||||
int StartByte,
|
||||
int ByteCount,
|
||||
bool OpaqueSize);
|
||||
|
||||
/// <summary>
|
||||
/// Plan a list of byte-range reads from <paramref name="tags"/>. Same-area /
|
||||
/// same-DB rows are sorted by <see cref="TagSpec.StartByte"/> then merged
|
||||
/// greedily when the gap between their byte ranges is <=
|
||||
/// <paramref name="gapMergeBytes"/>. Opaque-size rows always emit as their
|
||||
/// own single-tag range and never extend a sibling block.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Order of returned ranges is not significant — the driver issues them
|
||||
/// sequentially against the same connection gate so wire-level ordering is
|
||||
/// determined by the loop, not by this list. The planner DOES preserve
|
||||
/// the caller-index inside each range so the per-tag decode result lands
|
||||
/// in the correct slot of the response array.
|
||||
/// </remarks>
|
||||
internal static List<BlockReadRange> Plan(IReadOnlyList<TagSpec> tags, int gapMergeBytes = DefaultGapMergeBytes)
|
||||
{
|
||||
if (gapMergeBytes < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(gapMergeBytes), "Gap-merge threshold must be non-negative.");
|
||||
var ranges = new List<BlockReadRange>(tags.Count);
|
||||
if (tags.Count == 0) return ranges;
|
||||
|
||||
// Phase 1: opaque rows emit as standalone single-tag ranges. Strip them
|
||||
// out of the merge candidate set so neighbour ranges don't accidentally
|
||||
// straddle a STRING header / DTL block.
|
||||
var mergeable = new List<TagSpec>(tags.Count);
|
||||
foreach (var t in tags)
|
||||
{
|
||||
if (t.OpaqueSize)
|
||||
{
|
||||
ranges.Add(new BlockReadRange(
|
||||
t.Area, t.DbNumber, t.StartByte, t.ByteCount,
|
||||
[new TagSlice(t.CallerIndex, OffsetInBlock: 0, t.ByteCount)]));
|
||||
}
|
||||
else
|
||||
{
|
||||
mergeable.Add(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: bucket by (Area, DbNumber). Memory M and DataBlock DB1 (etc.)
|
||||
// share neither the wire request type nor an addressable space, so they
|
||||
// can never coalesce.
|
||||
var groups = mergeable.GroupBy(t => (t.Area, t.DbNumber));
|
||||
foreach (var group in groups)
|
||||
{
|
||||
// Sort ascending by start byte so the greedy merge below is O(n).
|
||||
// Stable secondary sort on caller index keeps tag-slice ordering
|
||||
// deterministic for tags with identical byte offsets.
|
||||
var sorted = group
|
||||
.OrderBy(t => t.StartByte)
|
||||
.ThenBy(t => t.CallerIndex)
|
||||
.ToList();
|
||||
|
||||
var blockStart = sorted[0].StartByte;
|
||||
var blockEnd = sorted[0].StartByte + sorted[0].ByteCount;
|
||||
var blockSlices = new List<TagSlice>
|
||||
{
|
||||
new(sorted[0].CallerIndex, 0, sorted[0].ByteCount),
|
||||
};
|
||||
|
||||
for (var i = 1; i < sorted.Count; i++)
|
||||
{
|
||||
var t = sorted[i];
|
||||
var gap = t.StartByte - blockEnd;
|
||||
// gap < 0 means the next tag overlaps with the current block — treat
|
||||
// as zero-gap merge (overlap is fine, the slice just reuses earlier
|
||||
// bytes). gap <= threshold = merge; otherwise close the current
|
||||
// block and start a new one.
|
||||
if (gap <= gapMergeBytes)
|
||||
{
|
||||
var newEnd = Math.Max(blockEnd, t.StartByte + t.ByteCount);
|
||||
blockSlices.Add(new TagSlice(t.CallerIndex, t.StartByte - blockStart, t.ByteCount));
|
||||
blockEnd = newEnd;
|
||||
}
|
||||
else
|
||||
{
|
||||
ranges.Add(new BlockReadRange(
|
||||
group.Key.Area, group.Key.DbNumber, blockStart, blockEnd - blockStart, blockSlices));
|
||||
blockStart = t.StartByte;
|
||||
blockEnd = t.StartByte + t.ByteCount;
|
||||
blockSlices = [new TagSlice(t.CallerIndex, 0, t.ByteCount)];
|
||||
}
|
||||
}
|
||||
ranges.Add(new BlockReadRange(
|
||||
group.Key.Area, group.Key.DbNumber, blockStart, blockEnd - blockStart, blockSlices));
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="tag"/>'s on-wire width is variable / header-prefixed.
|
||||
/// Such tags MUST NOT participate in block coalescing because the slice into a
|
||||
/// coalesced byte buffer would land at a wrong offset for any neighbour.
|
||||
/// </summary>
|
||||
internal static bool IsOpaqueSize(S7TagDefinition tag)
|
||||
{
|
||||
// Variable-width string types — STRING/WSTRING carry a 2-byte (or 4-byte)
|
||||
// header and the actual length depends on the runtime value, not the
|
||||
// declared StringLength. CHAR/WCHAR are fixed-width (1 / 2 bytes) but
|
||||
// routed via the per-tag string codec path, so coalescing them would
|
||||
// bypass the codec; treat them as opaque to keep the decode surface
|
||||
// unchanged.
|
||||
if (tag.DataType is S7DataType.String or S7DataType.WString
|
||||
or S7DataType.Char or S7DataType.WChar)
|
||||
return true;
|
||||
|
||||
// Structured timestamps — DTL is 12 bytes, DT is 8 bytes BCD-encoded;
|
||||
// both decode through S7DateTimeCodec and would silently mis-decode if
|
||||
// the slice landed mid-block. S5TIME/TIME/TOD/DATE are fixed-width 2/4
|
||||
// bytes but currently flow through the per-tag codec path; treat them
|
||||
// all as opaque so the planner emits a single-tag range and the existing
|
||||
// codec dispatch stays the source of truth for date/time decode.
|
||||
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime
|
||||
or S7DataType.S5Time or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
|
||||
return true;
|
||||
|
||||
// Arrays opt out: per-tag width is N × elementBytes, the slice must be
|
||||
// exact. Routing them as opaque keeps the array-aware byte-range read
|
||||
// path in S7Driver.ReadOneAsync.
|
||||
if (tag.ElementCount is int n && n > 1)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Byte width of a packable scalar tag for byte-range coalescing. Mirrors the
|
||||
/// size suffix the address grammar carried (<see cref="S7Size.Bit"/>=1 byte
|
||||
/// because reading a single bit still requires reading the containing byte;
|
||||
/// bit-extraction happens in the slice step).
|
||||
/// </summary>
|
||||
internal static int ScalarByteCount(S7Size size) => size switch
|
||||
{
|
||||
S7Size.Bit => 1,
|
||||
S7Size.Byte => 1,
|
||||
S7Size.Word => 2,
|
||||
S7Size.DWord => 4,
|
||||
S7Size.LWord => 8,
|
||||
_ => throw new InvalidOperationException($"Unknown S7Size {size}"),
|
||||
};
|
||||
}
|
||||
358
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DateTimeCodec.cs
Normal file
358
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DateTimeCodec.cs
Normal file
@@ -0,0 +1,358 @@
|
||||
using System.Buffers.Binary;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Byte-level codecs for the six Siemens S7 date/time-shaped types: DTL, DATE_AND_TIME
|
||||
/// (DT), S5TIME, TIME, TIME_OF_DAY (TOD), DATE. Pulled out of <see cref="S7Driver"/> so
|
||||
/// the encoding rules are unit-testable against golden byte vectors without standing
|
||||
/// up a Plc instance — same pattern as <see cref="S7StringCodec"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire formats (all big-endian, matching S7's native byte order):
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>DTL</b> (12 bytes): year UInt16 BE / month / day / day-of-week / hour /
|
||||
/// minute / second (1 byte each) / nanoseconds UInt32 BE. Year range 1970-2554.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>DATE_AND_TIME (DT)</b> (8 bytes BCD): year-since-1990 / month / day / hour /
|
||||
/// minute / second (1 BCD byte each) + ms (3 BCD digits packed in 1.5 bytes) +
|
||||
/// day-of-week (1 BCD digit, 1=Sunday..7=Saturday). Years 90-99 → 1990-1999;
|
||||
/// years 00-89 → 2000-2089.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>S5TIME</b> (16 bits): bits 15..14 reserved (0), bits 13..12 timebase
|
||||
/// (00=10ms, 01=100ms, 10=1s, 11=10s), bits 11..0 = 3-digit BCD count (0-999).
|
||||
/// Total range 0..9990s.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>TIME</b> (Int32 ms BE): signed milliseconds. Negative durations allowed.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>TOD</b> (UInt32 ms BE): milliseconds since midnight, 0..86399999.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>DATE</b> (UInt16 BE): days since 1990-01-01. Range 0..65535 (1990-2168).
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Uninitialized PLC bytes</b>: an all-zero DTL or DT buffer (year 0 / month 0)
|
||||
/// is rejected as <see cref="InvalidDataException"/> rather than decoded as
|
||||
/// year-0001 garbage — operators see "BadOutOfRange" instead of a misleading
|
||||
/// valid-but-wrong timestamp.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class S7DateTimeCodec
|
||||
{
|
||||
// ---- DTL (12 bytes) ----
|
||||
|
||||
/// <summary>Wire size of an S7 DTL value.</summary>
|
||||
public const int DtlSize = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Decode a 12-byte DTL buffer into a DateTime. Throws
|
||||
/// <see cref="InvalidDataException"/> when the buffer is uninitialized
|
||||
/// (all-zero year+month) or when components are out of range.
|
||||
/// </summary>
|
||||
public static DateTime DecodeDtl(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != DtlSize)
|
||||
throw new InvalidDataException($"S7 DTL expected {DtlSize} bytes, got {bytes.Length}");
|
||||
|
||||
int year = BinaryPrimitives.ReadUInt16BigEndian(bytes.Slice(0, 2));
|
||||
int month = bytes[2];
|
||||
int day = bytes[3];
|
||||
// bytes[4] = day-of-week (1=Sunday..7=Saturday); ignored on read — the .NET
|
||||
// DateTime carries its own and the PLC value can be inconsistent on uninit data.
|
||||
int hour = bytes[5];
|
||||
int minute = bytes[6];
|
||||
int second = bytes[7];
|
||||
uint nanos = BinaryPrimitives.ReadUInt32BigEndian(bytes.Slice(8, 4));
|
||||
|
||||
if (year == 0 && month == 0 && day == 0)
|
||||
throw new InvalidDataException("S7 DTL is uninitialized (all-zero year/month/day)");
|
||||
if (year is < 1970 or > 2554)
|
||||
throw new InvalidDataException($"S7 DTL year {year} out of range 1970..2554");
|
||||
if (month is < 1 or > 12)
|
||||
throw new InvalidDataException($"S7 DTL month {month} out of range 1..12");
|
||||
if (day is < 1 or > 31)
|
||||
throw new InvalidDataException($"S7 DTL day {day} out of range 1..31");
|
||||
if (hour > 23) throw new InvalidDataException($"S7 DTL hour {hour} out of range 0..23");
|
||||
if (minute > 59) throw new InvalidDataException($"S7 DTL minute {minute} out of range 0..59");
|
||||
if (second > 59) throw new InvalidDataException($"S7 DTL second {second} out of range 0..59");
|
||||
if (nanos > 999_999_999)
|
||||
throw new InvalidDataException($"S7 DTL nanoseconds {nanos} out of range 0..999999999");
|
||||
|
||||
// .NET DateTime resolution is 100 ns ticks (1 tick = 100 ns).
|
||||
var dt = new DateTime(year, month, day, hour, minute, second, DateTimeKind.Unspecified);
|
||||
return dt.AddTicks(nanos / 100);
|
||||
}
|
||||
|
||||
/// <summary>Encode a DateTime as a 12-byte DTL buffer.</summary>
|
||||
public static byte[] EncodeDtl(DateTime value)
|
||||
{
|
||||
if (value.Year is < 1970 or > 2554)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DTL year must be 1970..2554");
|
||||
|
||||
var buf = new byte[DtlSize];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), (ushort)value.Year);
|
||||
buf[2] = (byte)value.Month;
|
||||
buf[3] = (byte)value.Day;
|
||||
// S7 day-of-week: 1=Sunday..7=Saturday. .NET DayOfWeek: Sunday=0..Saturday=6.
|
||||
buf[4] = (byte)((int)value.DayOfWeek + 1);
|
||||
buf[5] = (byte)value.Hour;
|
||||
buf[6] = (byte)value.Minute;
|
||||
buf[7] = (byte)value.Second;
|
||||
|
||||
// Sub-second portion → nanoseconds. 1 tick = 100 ns, so ticks % 10_000_000 gives
|
||||
// the fractional second in ticks; multiply by 100 for nanoseconds.
|
||||
long fracTicks = value.Ticks % TimeSpan.TicksPerSecond;
|
||||
uint nanos = (uint)(fracTicks * 100);
|
||||
BinaryPrimitives.WriteUInt32BigEndian(buf.AsSpan(8, 4), nanos);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- DATE_AND_TIME / DT (8 bytes BCD) ----
|
||||
|
||||
/// <summary>Wire size of an S7 DATE_AND_TIME value.</summary>
|
||||
public const int DtSize = 8;
|
||||
|
||||
/// <summary>
|
||||
/// Decode an 8-byte DATE_AND_TIME (BCD) buffer into a DateTime. Year encoding:
|
||||
/// 90..99 → 1990..1999, 00..89 → 2000..2089 (per Siemens spec).
|
||||
/// </summary>
|
||||
public static DateTime DecodeDt(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != DtSize)
|
||||
throw new InvalidDataException($"S7 DATE_AND_TIME expected {DtSize} bytes, got {bytes.Length}");
|
||||
|
||||
int yy = FromBcd(bytes[0]);
|
||||
int month = FromBcd(bytes[1]);
|
||||
int day = FromBcd(bytes[2]);
|
||||
int hour = FromBcd(bytes[3]);
|
||||
int minute = FromBcd(bytes[4]);
|
||||
int second = FromBcd(bytes[5]);
|
||||
|
||||
// bytes[6] and high nibble of bytes[7] = milliseconds (3 BCD digits).
|
||||
// Low nibble of bytes[7] = day-of-week (1=Sunday..7=Saturday); ignored on read.
|
||||
int msHigh = (bytes[6] >> 4) & 0xF;
|
||||
int msMid = bytes[6] & 0xF;
|
||||
int msLow = (bytes[7] >> 4) & 0xF;
|
||||
if (msHigh > 9 || msMid > 9 || msLow > 9)
|
||||
throw new InvalidDataException($"S7 DT ms BCD digits invalid: {msHigh:X}{msMid:X}{msLow:X}");
|
||||
int ms = msHigh * 100 + msMid * 10 + msLow;
|
||||
|
||||
if (yy == 0 && month == 0 && day == 0)
|
||||
throw new InvalidDataException("S7 DT is uninitialized (all-zero year/month/day)");
|
||||
|
||||
int year = yy >= 90 ? 1900 + yy : 2000 + yy;
|
||||
if (month is < 1 or > 12) throw new InvalidDataException($"S7 DT month {month} out of range 1..12");
|
||||
if (day is < 1 or > 31) throw new InvalidDataException($"S7 DT day {day} out of range 1..31");
|
||||
if (hour > 23) throw new InvalidDataException($"S7 DT hour {hour} out of range 0..23");
|
||||
if (minute > 59) throw new InvalidDataException($"S7 DT minute {minute} out of range 0..59");
|
||||
if (second > 59) throw new InvalidDataException($"S7 DT second {second} out of range 0..59");
|
||||
|
||||
return new DateTime(year, month, day, hour, minute, second, ms, DateTimeKind.Unspecified);
|
||||
}
|
||||
|
||||
/// <summary>Encode a DateTime as an 8-byte DATE_AND_TIME (BCD) buffer.</summary>
|
||||
public static byte[] EncodeDt(DateTime value)
|
||||
{
|
||||
if (value.Year is < 1990 or > 2089)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DATE_AND_TIME year must be 1990..2089");
|
||||
|
||||
int yy = value.Year >= 2000 ? value.Year - 2000 : value.Year - 1900;
|
||||
int ms = value.Millisecond;
|
||||
// S7 day-of-week: 1=Sunday..7=Saturday.
|
||||
int dow = (int)value.DayOfWeek + 1;
|
||||
|
||||
var buf = new byte[DtSize];
|
||||
buf[0] = ToBcd(yy);
|
||||
buf[1] = ToBcd(value.Month);
|
||||
buf[2] = ToBcd(value.Day);
|
||||
buf[3] = ToBcd(value.Hour);
|
||||
buf[4] = ToBcd(value.Minute);
|
||||
buf[5] = ToBcd(value.Second);
|
||||
// ms = 3 digits packed across bytes [6] (high+mid nibbles) and [7] high nibble.
|
||||
buf[6] = (byte)(((ms / 100) << 4) | ((ms / 10) % 10));
|
||||
buf[7] = (byte)((((ms % 10) & 0xF) << 4) | (dow & 0xF));
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- S5TIME (16 bits BCD) ----
|
||||
|
||||
/// <summary>Wire size of an S7 S5TIME value.</summary>
|
||||
public const int S5TimeSize = 2;
|
||||
|
||||
/// <summary>
|
||||
/// Decode a 2-byte S5TIME buffer into a TimeSpan. Layout:
|
||||
/// <c>0000 TTBB BBBB BBBB</c> where TT is the timebase (00=10ms, 01=100ms,
|
||||
/// 10=1s, 11=10s) and BBB is the 3-digit BCD count (0..999).
|
||||
/// </summary>
|
||||
public static TimeSpan DecodeS5Time(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != S5TimeSize)
|
||||
throw new InvalidDataException($"S7 S5TIME expected {S5TimeSize} bytes, got {bytes.Length}");
|
||||
|
||||
int hi = bytes[0];
|
||||
int lo = bytes[1];
|
||||
int tb = (hi >> 4) & 0x3;
|
||||
int d2 = hi & 0xF;
|
||||
int d1 = (lo >> 4) & 0xF;
|
||||
int d0 = lo & 0xF;
|
||||
if (d2 > 9 || d1 > 9 || d0 > 9)
|
||||
throw new InvalidDataException($"S7 S5TIME BCD digits invalid: {d2:X}{d1:X}{d0:X}");
|
||||
|
||||
int count = d2 * 100 + d1 * 10 + d0;
|
||||
long unitMs = tb switch
|
||||
{
|
||||
0 => 10L,
|
||||
1 => 100L,
|
||||
2 => 1000L,
|
||||
3 => 10_000L,
|
||||
_ => throw new InvalidDataException($"S7 S5TIME timebase {tb} invalid"),
|
||||
};
|
||||
return TimeSpan.FromMilliseconds(count * unitMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode a TimeSpan as a 2-byte S5TIME. Picks the smallest timebase that fits
|
||||
/// <paramref name="value"/> in 999 units. Rejects negative or > 9990s durations
|
||||
/// and any value not a multiple of the chosen timebase.
|
||||
/// </summary>
|
||||
public static byte[] EncodeS5Time(TimeSpan value)
|
||||
{
|
||||
if (value < TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 S5TIME must be non-negative");
|
||||
long totalMs = (long)value.TotalMilliseconds;
|
||||
if (totalMs > 9_990_000)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 S5TIME max is 9990 seconds");
|
||||
|
||||
int tb;
|
||||
long unit;
|
||||
if (totalMs <= 9_990 && totalMs % 10 == 0) { tb = 0; unit = 10; }
|
||||
else if (totalMs <= 99_900 && totalMs % 100 == 0) { tb = 1; unit = 100; }
|
||||
else if (totalMs <= 999_000 && totalMs % 1000 == 0) { tb = 2; unit = 1_000; }
|
||||
else if (totalMs % 10_000 == 0) { tb = 3; unit = 10_000; }
|
||||
else
|
||||
throw new ArgumentException(
|
||||
$"S7 S5TIME duration {value} cannot be represented in any timebase without truncation",
|
||||
nameof(value));
|
||||
|
||||
long count = totalMs / unit;
|
||||
if (count > 999)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 S5TIME count exceeds 999 in chosen timebase");
|
||||
|
||||
int d2 = (int)(count / 100);
|
||||
int d1 = (int)((count / 10) % 10);
|
||||
int d0 = (int)(count % 10);
|
||||
|
||||
var buf = new byte[2];
|
||||
buf[0] = (byte)(((tb & 0x3) << 4) | (d2 & 0xF));
|
||||
buf[1] = (byte)(((d1 & 0xF) << 4) | (d0 & 0xF));
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- TIME (Int32 ms BE) ----
|
||||
|
||||
/// <summary>Wire size of an S7 TIME value.</summary>
|
||||
public const int TimeSize = 4;
|
||||
|
||||
/// <summary>Decode a 4-byte TIME buffer into a TimeSpan (signed milliseconds).</summary>
|
||||
public static TimeSpan DecodeTime(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != TimeSize)
|
||||
throw new InvalidDataException($"S7 TIME expected {TimeSize} bytes, got {bytes.Length}");
|
||||
int ms = BinaryPrimitives.ReadInt32BigEndian(bytes);
|
||||
return TimeSpan.FromMilliseconds(ms);
|
||||
}
|
||||
|
||||
/// <summary>Encode a TimeSpan as a 4-byte TIME (signed Int32 milliseconds, big-endian).</summary>
|
||||
public static byte[] EncodeTime(TimeSpan value)
|
||||
{
|
||||
long totalMs = (long)value.TotalMilliseconds;
|
||||
if (totalMs is < int.MinValue or > int.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 TIME exceeds Int32 ms range");
|
||||
var buf = new byte[TimeSize];
|
||||
BinaryPrimitives.WriteInt32BigEndian(buf, (int)totalMs);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- TOD / TIME_OF_DAY (UInt32 ms BE, 0..86399999) ----
|
||||
|
||||
/// <summary>Wire size of an S7 TIME_OF_DAY value.</summary>
|
||||
public const int TodSize = 4;
|
||||
|
||||
/// <summary>Decode a 4-byte TOD buffer into a TimeSpan (ms since midnight).</summary>
|
||||
public static TimeSpan DecodeTod(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != TodSize)
|
||||
throw new InvalidDataException($"S7 TOD expected {TodSize} bytes, got {bytes.Length}");
|
||||
uint ms = BinaryPrimitives.ReadUInt32BigEndian(bytes);
|
||||
if (ms > 86_399_999)
|
||||
throw new InvalidDataException($"S7 TOD value {ms} exceeds 86399999 ms (one day)");
|
||||
return TimeSpan.FromMilliseconds(ms);
|
||||
}
|
||||
|
||||
/// <summary>Encode a TimeSpan as a 4-byte TOD (UInt32 ms since midnight, big-endian).</summary>
|
||||
public static byte[] EncodeTod(TimeSpan value)
|
||||
{
|
||||
if (value < TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 TOD must be non-negative");
|
||||
long totalMs = (long)value.TotalMilliseconds;
|
||||
if (totalMs > 86_399_999)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 TOD max is 86399999 ms (23:59:59.999)");
|
||||
var buf = new byte[TodSize];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(buf, (uint)totalMs);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- DATE (UInt16 BE, days since 1990-01-01) ----
|
||||
|
||||
/// <summary>Wire size of an S7 DATE value.</summary>
|
||||
public const int DateSize = 2;
|
||||
|
||||
/// <summary>S7 DATE epoch — 1990-01-01 (UTC-unspecified per Siemens spec).</summary>
|
||||
public static readonly DateTime DateEpoch = new(1990, 1, 1, 0, 0, 0, DateTimeKind.Unspecified);
|
||||
|
||||
/// <summary>Decode a 2-byte DATE buffer into a DateTime.</summary>
|
||||
public static DateTime DecodeDate(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != DateSize)
|
||||
throw new InvalidDataException($"S7 DATE expected {DateSize} bytes, got {bytes.Length}");
|
||||
ushort days = BinaryPrimitives.ReadUInt16BigEndian(bytes);
|
||||
return DateEpoch.AddDays(days);
|
||||
}
|
||||
|
||||
/// <summary>Encode a DateTime as a 2-byte DATE (UInt16 days since 1990-01-01, big-endian).</summary>
|
||||
public static byte[] EncodeDate(DateTime value)
|
||||
{
|
||||
var days = (value.Date - DateEpoch).TotalDays;
|
||||
if (days is < 0 or > ushort.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "S7 DATE must be 1990-01-01..2168-06-06");
|
||||
var buf = new byte[DateSize];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf, (ushort)days);
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ---- BCD helpers ----
|
||||
|
||||
/// <summary>Decode a single BCD byte (each nibble must be a decimal digit 0-9).</summary>
|
||||
private static int FromBcd(byte b)
|
||||
{
|
||||
int hi = (b >> 4) & 0xF;
|
||||
int lo = b & 0xF;
|
||||
if (hi > 9 || lo > 9)
|
||||
throw new InvalidDataException($"S7 BCD byte 0x{b:X2} has non-decimal nibble");
|
||||
return hi * 10 + lo;
|
||||
}
|
||||
|
||||
/// <summary>Encode a 0-99 value as a single BCD byte.</summary>
|
||||
private static byte ToBcd(int value)
|
||||
{
|
||||
if (value is < 0 or > 99)
|
||||
throw new ArgumentOutOfRangeException(nameof(value), value, "BCD byte source must be 0..99");
|
||||
return (byte)(((value / 10) << 4) | (value % 10));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using S7.Net;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
@@ -53,6 +55,15 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
/// <summary>OPC UA StatusCode used when S7 returns <c>ErrorCode.WrongCPU</c> / PUT/GET disabled.</summary>
|
||||
private const uint StatusBadDeviceFailure = 0x80550000u;
|
||||
|
||||
/// <summary>
|
||||
/// Hard upper bound on <see cref="S7TagDefinition.ElementCount"/>. The S7 PDU envelope
|
||||
/// for negotiated default 240-byte and extended 960-byte payloads cannot fit a single
|
||||
/// byte-range read larger than ~960 bytes, so a Float64 array of more than ~120
|
||||
/// elements is already lossy. 8000 is an order-of-magnitude generous ceiling that still
|
||||
/// rejects obvious config typos (e.g. ElementCount = 65535) at init time.
|
||||
/// </summary>
|
||||
internal const int MaxArrayElements = 8000;
|
||||
|
||||
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -76,6 +87,31 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private bool _disposed;
|
||||
|
||||
// ---- Block-read coalescing diagnostics (PR-S7-B2) ----
|
||||
//
|
||||
// Counters surface through DriverHealth.Diagnostics so the driver-diagnostics
|
||||
// RPC and integration tests can verify wire-level reduction without needing
|
||||
// access to the underlying S7.Net PDU stream. Names match the
|
||||
// "<DriverType>.<Counter>" convention adopted for the modbus and opcuaclient
|
||||
// drivers — see decision #154.
|
||||
private long _totalBlockReads; // Plc.ReadBytesAsync calls issued by the coalesced path
|
||||
private long _totalMultiVarBatches; // Plc.ReadMultipleVarsAsync calls issued
|
||||
private long _totalSingleReads; // per-tag ReadOneAsync fallbacks
|
||||
|
||||
/// <summary>
|
||||
/// Total <c>Plc.ReadBytesAsync</c> calls the coalesced byte-range path issued.
|
||||
/// Test-only entry point for the integration assertion that 50 contiguous DBWs
|
||||
/// coalesce into exactly 1 byte-range read.
|
||||
/// </summary>
|
||||
internal long TotalBlockReads => Interlocked.Read(ref _totalBlockReads);
|
||||
|
||||
/// <summary>
|
||||
/// Total <c>Plc.ReadMultipleVarsAsync</c> batches issued. For a fully-coalesced
|
||||
/// contiguous workload this stays at 0 — every tag flows through the byte-range
|
||||
/// path instead.
|
||||
/// </summary>
|
||||
internal long TotalMultiVarBatches => Interlocked.Read(ref _totalMultiVarBatches);
|
||||
|
||||
public string DriverInstanceId => driverInstanceId;
|
||||
public string DriverType => "S7";
|
||||
|
||||
@@ -84,6 +120,34 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
// Parse + validate every tag before opening the TCP socket so config bugs
|
||||
// (bad address, oversized array, unsupported array element) surface as
|
||||
// FormatException without waiting on a connect timeout. Per the v1 driver-config
|
||||
// story this lets the Admin UI's "Save" round-trip stay sub-second on bad input.
|
||||
_tagsByName.Clear();
|
||||
_parsedByName.Clear();
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
// Pass CpuType so V-memory addresses (S7-200 / S7-200 Smart / LOGO!) resolve
|
||||
// against the device's family-specific DB mapping.
|
||||
var parsed = S7AddressParser.Parse(t.Address, _options.CpuType); // throws FormatException
|
||||
if (t.ElementCount is int n && n > 1)
|
||||
{
|
||||
// Array sanity: cap at S7 PDU realistic limit, reject variable-width
|
||||
// element types and BOOL (packed-bit layout) up-front so a config typo
|
||||
// fails at init instead of surfacing as BadInternalError on every read.
|
||||
if (n > MaxArrayElements)
|
||||
throw new FormatException(
|
||||
$"S7 tag '{t.Name}' ElementCount {n} exceeds S7 PDU realistic limit ({MaxArrayElements})");
|
||||
if (!IsArrayElementSupported(t.DataType))
|
||||
throw new FormatException(
|
||||
$"S7 tag '{t.Name}' DataType {t.DataType} not supported as an array element " +
|
||||
$"(variable-width string types and BOOL packed-bit arrays are a follow-up)");
|
||||
}
|
||||
_tagsByName[t.Name] = t;
|
||||
_parsedByName[t.Name] = parsed;
|
||||
}
|
||||
|
||||
var plc = new Plc(_options.CpuType, _options.Host, _options.Port, _options.Rack, _options.Slot);
|
||||
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
|
||||
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
|
||||
@@ -97,18 +161,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
|
||||
Plc = plc;
|
||||
|
||||
// Parse every tag's address once at init so config typos fail fast here instead
|
||||
// of surfacing as BadInternalError on every Read against the bad tag. The parser
|
||||
// also rejects bit-offset > 7, DB 0, unknown area letters, etc.
|
||||
_tagsByName.Clear();
|
||||
_parsedByName.Clear();
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
|
||||
_tagsByName[t.Name] = t;
|
||||
_parsedByName[t.Name] = parsed;
|
||||
}
|
||||
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
|
||||
// Kick off the probe loop once the connection is up. Initial HostState stays
|
||||
@@ -179,6 +231,14 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Phase 1: classify each request into (a) unknown / not-found, (b) packable
|
||||
// scalar (Bool/Byte/Int16/UInt16/Int32/UInt32/Float32/Float64) which can
|
||||
// potentially coalesce into a byte-range read, or (c) per-tag fallback
|
||||
// (arrays, strings, dates, 64-bit ints, UDT-fanout). Packable tags feed
|
||||
// the block-coalescing planner first (PR-S7-B2); whatever survives as a
|
||||
// singleton range falls through to the multi-var packer (PR-S7-B1).
|
||||
var packableIndexes = new List<int>(fullReferences.Count);
|
||||
var fallbackIndexes = new List<int>();
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var name = fullReferences[i];
|
||||
@@ -187,39 +247,415 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
var addr = _parsedByName[name];
|
||||
if (S7ReadPacker.IsPackable(tag, addr)) packableIndexes.Add(i);
|
||||
else fallbackIndexes.Add(i);
|
||||
}
|
||||
|
||||
// Phase 2a: block-read coalescing — group same-area / same-DB packable
|
||||
// tags into contiguous byte ranges (gap-merge threshold from
|
||||
// S7DriverOptions.BlockCoalescingGapBytes, default 16). Multi-tag ranges
|
||||
// dispatch via Plc.ReadBytesAsync; singleton ranges fall through to the
|
||||
// multi-var packer below.
|
||||
var singletons = new List<int>();
|
||||
if (packableIndexes.Count > 0)
|
||||
{
|
||||
var specs = new List<S7BlockCoalescingPlanner.TagSpec>(packableIndexes.Count);
|
||||
foreach (var idx in packableIndexes)
|
||||
{
|
||||
var value = await ReadOneAsync(plc, tag, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
var tag = _tagsByName[fullReferences[idx]];
|
||||
var addr = _parsedByName[fullReferences[idx]];
|
||||
specs.Add(new S7BlockCoalescingPlanner.TagSpec(
|
||||
CallerIndex: idx,
|
||||
Area: addr.Area,
|
||||
DbNumber: addr.DbNumber,
|
||||
StartByte: addr.ByteOffset,
|
||||
ByteCount: S7BlockCoalescingPlanner.ScalarByteCount(addr.Size),
|
||||
OpaqueSize: false));
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs, _options.BlockCoalescingGapBytes);
|
||||
|
||||
foreach (var range in ranges)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||
if (range.Tags.Count == 1)
|
||||
{
|
||||
// Singleton — let the multi-var packer batch it with other
|
||||
// singletons in the same ReadAsync call. Cheaper than its
|
||||
// own one-tag ReadBytesAsync round-trip.
|
||||
singletons.Add(range.Tags[0].CallerIndex);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ReadCoalescedRangeAsync(plc, range, fullReferences, results, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (global::S7.Net.PlcException pex)
|
||||
}
|
||||
|
||||
// Phase 2b: bin-pack residual singletons through ReadMultipleVarsAsync.
|
||||
// On a per-batch S7.Net failure the whole batch falls back to ReadOneAsync
|
||||
// per tag — that way one bad item doesn't poison the rest of the batch
|
||||
// and each tag still gets its own per-item StatusCode (BadDeviceFailure
|
||||
// for PUT/GET refusal, BadCommunicationError for transport faults).
|
||||
if (singletons.Count > 0)
|
||||
{
|
||||
var budget = S7ReadPacker.ItemBudget(S7ReadPacker.DefaultPduSize);
|
||||
var batches = S7ReadPacker.BinPack(singletons, budget);
|
||||
foreach (var batch in batches)
|
||||
{
|
||||
// S7.Net's PlcException carries an ErrorCode; PUT/GET-disabled on
|
||||
// S7-1200/1500 surfaces here. Map to BadDeviceFailure so operators see a
|
||||
// device-config problem (toggle PUT/GET in TIA Portal) rather than a
|
||||
// transient fault — per driver-specs.md §5.
|
||||
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
await ReadBatchAsync(plc, batch, fullReferences, results, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: per-tag fallback for everything that can't pack into a single
|
||||
// DataItem. Keeps the existing decode path as the source of truth for
|
||||
// string/date/array/64-bit semantics.
|
||||
foreach (var i in fallbackIndexes)
|
||||
{
|
||||
var tag = _tagsByName[fullReferences[i]];
|
||||
results[i] = await ReadOneAsSnapshotAsync(plc, tag, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally { _gate.Release(); }
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issue one coalesced <c>Plc.ReadBytesAsync</c> covering
|
||||
/// <paramref name="range"/> and slice the response per tag. On a transport
|
||||
/// fault the whole range falls back to per-tag <see cref="ReadOneAsSnapshotAsync"/>
|
||||
/// so a single bad slot doesn't poison N-1 good neighbours.
|
||||
/// </summary>
|
||||
private async Task ReadCoalescedRangeAsync(
|
||||
global::S7.Net.Plc plc,
|
||||
S7BlockCoalescingPlanner.BlockReadRange range,
|
||||
IReadOnlyList<string> fullReferences,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime now,
|
||||
CancellationToken ct)
|
||||
{
|
||||
byte[]? buf;
|
||||
try
|
||||
{
|
||||
Interlocked.Increment(ref _totalBlockReads);
|
||||
buf = await plc.ReadBytesAsync(MapArea(range.Area), range.DbNumber, range.StartByte, range.ByteCount, ct)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Block read fault → fan out per-tag so a bad address in the block
|
||||
// surfaces its own StatusCode and good neighbours can still retry
|
||||
// through the per-tag fallback path.
|
||||
foreach (var slice in range.Tags)
|
||||
{
|
||||
var tag = _tagsByName[fullReferences[slice.CallerIndex]];
|
||||
results[slice.CallerIndex] = await ReadOneAsSnapshotAsync(plc, tag, now, ct).ConfigureAwait(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (buf is null || buf.Length != range.ByteCount)
|
||||
{
|
||||
// Short / truncated PDU — same fan-out semantics as a transport fault.
|
||||
foreach (var slice in range.Tags)
|
||||
{
|
||||
results[slice.CallerIndex] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var slice in range.Tags)
|
||||
{
|
||||
var name = fullReferences[slice.CallerIndex];
|
||||
var tag = _tagsByName[name];
|
||||
var addr = _parsedByName[name];
|
||||
try
|
||||
{
|
||||
var value = DecodeScalarFromBlock(buf, slice.OffsetInBlock, tag, addr);
|
||||
results[slice.CallerIndex] = new DataValueSnapshot(value, 0u, now, now);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[slice.CallerIndex] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null, BuildDiagnostics());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode one packable scalar from a coalesced byte buffer. Mirrors the
|
||||
/// reinterpret table in <see cref="S7ReadPacker.DecodePackedValue"/> so the
|
||||
/// coalesced and per-tag-batch paths produce identical .NET types for the
|
||||
/// same wire bytes.
|
||||
/// </summary>
|
||||
private static object DecodeScalarFromBlock(byte[] buf, int offset, S7TagDefinition tag, S7ParsedAddress addr)
|
||||
{
|
||||
return (tag.DataType, addr.Size) switch
|
||||
{
|
||||
(S7DataType.Bool, S7Size.Bit) => ((buf[offset] >> addr.BitOffset) & 0x1) == 1,
|
||||
(S7DataType.Byte, S7Size.Byte) => buf[offset],
|
||||
(S7DataType.UInt16, S7Size.Word) => BinaryPrimitives.ReadUInt16BigEndian(buf.AsSpan(offset, 2)),
|
||||
(S7DataType.Int16, S7Size.Word) => BinaryPrimitives.ReadInt16BigEndian(buf.AsSpan(offset, 2)),
|
||||
(S7DataType.UInt32, S7Size.DWord) => BinaryPrimitives.ReadUInt32BigEndian(buf.AsSpan(offset, 4)),
|
||||
(S7DataType.Int32, S7Size.DWord) => BinaryPrimitives.ReadInt32BigEndian(buf.AsSpan(offset, 4)),
|
||||
(S7DataType.Float32, S7Size.DWord) =>
|
||||
BitConverter.UInt32BitsToSingle(BinaryPrimitives.ReadUInt32BigEndian(buf.AsSpan(offset, 4))),
|
||||
(S7DataType.Float64, S7Size.LWord) =>
|
||||
BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(buf.AsSpan(offset, 8))),
|
||||
_ => throw new System.IO.InvalidDataException(
|
||||
$"S7 block-decode: tag '{tag.Name}' declared {tag.DataType} but address parsed Size={addr.Size}"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the wire-level coalescing counters surfaced through
|
||||
/// <see cref="DriverHealth.Diagnostics"/>. Names follow the
|
||||
/// <c>"<DriverType>.<Counter>"</c> convention so the driver-diagnostics
|
||||
/// RPC can render them in the Admin UI alongside Modbus / OPC UA Client
|
||||
/// metrics without a per-driver special-case.
|
||||
/// </summary>
|
||||
private IReadOnlyDictionary<string, double> BuildDiagnostics() => new Dictionary<string, double>
|
||||
{
|
||||
["S7.TotalBlockReads"] = Interlocked.Read(ref _totalBlockReads),
|
||||
["S7.TotalMultiVarBatches"] = Interlocked.Read(ref _totalMultiVarBatches),
|
||||
["S7.TotalSingleReads"] = Interlocked.Read(ref _totalSingleReads),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Read one packed batch via <c>Plc.ReadMultipleVarsAsync</c>. On batch
|
||||
/// success each <c>DataItem.Value</c> decodes into its tag's snapshot
|
||||
/// slot; on batch failure each tag in the batch falls back to
|
||||
/// <see cref="ReadOneAsSnapshotAsync"/> so the failure fans out per-tag instead
|
||||
/// of poisoning the whole batch with one StatusCode.
|
||||
/// </summary>
|
||||
private async Task ReadBatchAsync(
|
||||
global::S7.Net.Plc plc,
|
||||
IReadOnlyList<int> batchIndexes,
|
||||
IReadOnlyList<string> fullReferences,
|
||||
DataValueSnapshot[] results,
|
||||
DateTime now,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var items = new List<global::S7.Net.Types.DataItem>(batchIndexes.Count);
|
||||
foreach (var idx in batchIndexes)
|
||||
{
|
||||
var name = fullReferences[idx];
|
||||
items.Add(S7ReadPacker.BuildDataItem(_tagsByName[name], _parsedByName[name]));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Interlocked.Increment(ref _totalMultiVarBatches);
|
||||
var responses = await plc.ReadMultipleVarsAsync(items, ct).ConfigureAwait(false);
|
||||
// S7.Net mutates the input list in place and also returns it; iterate by
|
||||
// index against the input list so we are agnostic to either contract.
|
||||
for (var k = 0; k < batchIndexes.Count; k++)
|
||||
{
|
||||
var idx = batchIndexes[k];
|
||||
var tag = _tagsByName[fullReferences[idx]];
|
||||
var raw = (responses != null && k < responses.Count ? responses[k] : items[k]).Value;
|
||||
if (raw is null)
|
||||
{
|
||||
results[idx] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
var decoded = S7ReadPacker.DecodePackedValue(tag, raw);
|
||||
results[idx] = new DataValueSnapshot(decoded, 0u, now, now);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[idx] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null, BuildDiagnostics());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Batch-level fault: most likely a single bad address poisoned the
|
||||
// multi-var response. Fall back to ReadOneAsync per tag in the batch so
|
||||
// good tags still surface a value and the offender gets its own StatusCode.
|
||||
foreach (var idx in batchIndexes)
|
||||
{
|
||||
var tag = _tagsByName[fullReferences[idx]];
|
||||
results[idx] = await ReadOneAsSnapshotAsync(plc, tag, now, ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single-tag read wrapped as a <see cref="DataValueSnapshot"/> with the same
|
||||
/// exception-to-StatusCode mapping the legacy per-tag loop applied. Shared
|
||||
/// between the fallback path and the post-batch retry path so the failure
|
||||
/// surface stays identical.
|
||||
/// </summary>
|
||||
private async Task<DataValueSnapshot> ReadOneAsSnapshotAsync(
|
||||
global::S7.Net.Plc plc, S7TagDefinition tag, DateTime now, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
Interlocked.Increment(ref _totalSingleReads);
|
||||
var value = await ReadOneAsync(plc, tag, ct).ConfigureAwait(false);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
return new DataValueSnapshot(value, 0u, now, now);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return new DataValueSnapshot(null, StatusBadNotSupported, null, now);
|
||||
}
|
||||
catch (global::S7.Net.PlcException pex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
|
||||
return new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
return new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
|
||||
// 1-D array path: one byte-range read covering N×elementBytes, sliced client-side.
|
||||
// Init-time validation guarantees only fixed-width element types reach here.
|
||||
if (tag.ElementCount is int n && n > 1)
|
||||
{
|
||||
var elemBytes = ArrayElementBytes(tag.DataType);
|
||||
var totalBytes = checked(n * elemBytes);
|
||||
if (addr.Size == S7Size.Bit)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7 Read type-mismatch: tag '{tag.Name}' is array of {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as bit-access; arrays require byte-addressing");
|
||||
|
||||
var arrBytes = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, totalBytes, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (arrBytes is null || arrBytes.Length != totalBytes)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7.Net returned {arrBytes?.Length ?? 0} bytes for array '{tag.Address}' (n={n}), expected {totalBytes}");
|
||||
|
||||
return SliceArray(arrBytes, tag.DataType, n, elemBytes);
|
||||
}
|
||||
|
||||
// String-shaped types (STRING/WSTRING/CHAR/WCHAR): S7.Net's string-keyed ReadAsync
|
||||
// has no syntax for these, so the driver issues a raw byte read and decodes via
|
||||
// S7StringCodec. Wire order is big-endian for the WSTRING/WCHAR UTF-16 payload.
|
||||
if (tag.DataType is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar)
|
||||
{
|
||||
if (addr.Size == S7Size.Bit)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as bit-access; string-shaped types require byte-addressing (e.g. DBB / MB / IB / QB)");
|
||||
|
||||
var (area, dbNum, off) = (addr.Area, addr.DbNumber, addr.ByteOffset);
|
||||
switch (tag.DataType)
|
||||
{
|
||||
case S7DataType.Char:
|
||||
{
|
||||
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, 1, ct).ConfigureAwait(false);
|
||||
if (b is null || b.Length != 1)
|
||||
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for CHAR '{tag.Address}', expected 1");
|
||||
return S7StringCodec.DecodeChar(b);
|
||||
}
|
||||
case S7DataType.WChar:
|
||||
{
|
||||
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, 2, ct).ConfigureAwait(false);
|
||||
if (b is null || b.Length != 2)
|
||||
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for WCHAR '{tag.Address}', expected 2");
|
||||
return S7StringCodec.DecodeWChar(b);
|
||||
}
|
||||
case S7DataType.String:
|
||||
{
|
||||
var max = tag.StringLength;
|
||||
var size = S7StringCodec.StringBufferSize(max);
|
||||
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, size, ct).ConfigureAwait(false);
|
||||
if (b is null || b.Length != size)
|
||||
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for STRING '{tag.Address}', expected {size}");
|
||||
return S7StringCodec.DecodeString(b, max);
|
||||
}
|
||||
case S7DataType.WString:
|
||||
{
|
||||
var max = tag.StringLength;
|
||||
var size = S7StringCodec.WStringBufferSize(max);
|
||||
var b = await plc.ReadBytesAsync(MapArea(area), dbNum, off, size, ct).ConfigureAwait(false);
|
||||
if (b is null || b.Length != size)
|
||||
throw new System.IO.InvalidDataException($"S7.Net returned {b?.Length ?? 0} bytes for WSTRING '{tag.Address}', expected {size}");
|
||||
return S7StringCodec.DecodeWString(b, max);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Date/time-shaped types (DTL/DT/S5TIME/TIME/TOD/DATE): S7.Net has no native size
|
||||
// suffix for any of these, so the driver issues a raw byte read at the address's
|
||||
// ByteOffset and decodes via S7DateTimeCodec. All require byte-addressing — bit-
|
||||
// access against a date/time tag is a config bug worth surfacing as a hard error.
|
||||
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime or S7DataType.S5Time
|
||||
or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
|
||||
{
|
||||
if (addr.Size == S7Size.Bit)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as bit-access; date/time types require byte-addressing");
|
||||
|
||||
int size = tag.DataType switch
|
||||
{
|
||||
S7DataType.Dtl => S7DateTimeCodec.DtlSize,
|
||||
S7DataType.DateAndTime => S7DateTimeCodec.DtSize,
|
||||
S7DataType.S5Time => S7DateTimeCodec.S5TimeSize,
|
||||
S7DataType.Time => S7DateTimeCodec.TimeSize,
|
||||
S7DataType.TimeOfDay => S7DateTimeCodec.TodSize,
|
||||
S7DataType.Date => S7DateTimeCodec.DateSize,
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
|
||||
var b = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, size, ct).ConfigureAwait(false);
|
||||
if (b is null || b.Length != size)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7.Net returned {b?.Length ?? 0} bytes for {tag.DataType} '{tag.Address}', expected {size}");
|
||||
|
||||
return tag.DataType switch
|
||||
{
|
||||
S7DataType.Dtl => S7DateTimeCodec.DecodeDtl(b),
|
||||
S7DataType.DateAndTime => S7DateTimeCodec.DecodeDt(b),
|
||||
// S5TIME/TIME/TOD surface as Int32 ms — DriverDataType has no Duration type;
|
||||
// OPC UA clients see a millisecond integer matching the IEC-1131 convention.
|
||||
S7DataType.S5Time => (int)S7DateTimeCodec.DecodeS5Time(b).TotalMilliseconds,
|
||||
S7DataType.Time => (int)S7DateTimeCodec.DecodeTime(b).TotalMilliseconds,
|
||||
S7DataType.TimeOfDay => (int)S7DateTimeCodec.DecodeTod(b).TotalMilliseconds,
|
||||
S7DataType.Date => S7DateTimeCodec.DecodeDate(b),
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
}
|
||||
|
||||
// 64-bit types: S7.Net's string-based ReadAsync has no LWord size suffix, so issue an
|
||||
// 8-byte ReadBytesAsync and convert big-endian in-process. Wire order on S7 is BE.
|
||||
if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64)
|
||||
{
|
||||
if (addr.Size != S7Size.LWord)
|
||||
throw new System.IO.InvalidDataException(
|
||||
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as Size={addr.Size}; 64-bit types require an LD/DBL/DBLD suffix");
|
||||
|
||||
var bytes = await plc.ReadBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, 8, ct)
|
||||
.ConfigureAwait(false);
|
||||
if (bytes is null || bytes.Length != 8)
|
||||
throw new System.IO.InvalidDataException($"S7.Net returned {bytes?.Length ?? 0} bytes for '{tag.Address}', expected 8");
|
||||
return tag.DataType switch
|
||||
{
|
||||
S7DataType.Int64 => BinaryPrimitives.ReadInt64BigEndian(bytes),
|
||||
S7DataType.UInt64 => BinaryPrimitives.ReadUInt64BigEndian(bytes),
|
||||
S7DataType.Float64 => BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(bytes)),
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
}
|
||||
|
||||
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
|
||||
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
|
||||
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
|
||||
@@ -238,10 +674,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
|
||||
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
||||
|
||||
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 reads land in a follow-up PR"),
|
||||
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 reads land in a follow-up PR"),
|
||||
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) reads land in a follow-up PR"),
|
||||
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"),
|
||||
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
|
||||
|
||||
_ => throw new System.IO.InvalidDataException(
|
||||
@@ -250,6 +682,18 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Map driver-internal <see cref="S7Area"/> to S7.Net's <see cref="global::S7.Net.DataType"/>.</summary>
|
||||
private static global::S7.Net.DataType MapArea(S7Area area) => area switch
|
||||
{
|
||||
S7Area.DataBlock => global::S7.Net.DataType.DataBlock,
|
||||
S7Area.Memory => global::S7.Net.DataType.Memory,
|
||||
S7Area.Input => global::S7.Net.DataType.Input,
|
||||
S7Area.Output => global::S7.Net.DataType.Output,
|
||||
S7Area.Timer => global::S7.Net.DataType.Timer,
|
||||
S7Area.Counter => global::S7.Net.DataType.Counter,
|
||||
_ => throw new InvalidOperationException($"Unknown S7Area {area}"),
|
||||
};
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
@@ -299,6 +743,102 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
|
||||
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
// 1-D array path: pack all N elements into a single buffer then push via WriteBytesAsync.
|
||||
// Init-time validation guarantees only fixed-width element types reach here.
|
||||
if (tag.ElementCount is int n && n > 1)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
if (addr.Size == S7Size.Bit)
|
||||
throw new InvalidOperationException(
|
||||
$"S7 Write type-mismatch: tag '{tag.Name}' is array of {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as bit-access; arrays require byte-addressing");
|
||||
if (value is null)
|
||||
throw new ArgumentNullException(nameof(value));
|
||||
var elemBytes = ArrayElementBytes(tag.DataType);
|
||||
var buf = PackArray(value, tag.DataType, n, elemBytes, tag.Name);
|
||||
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, buf, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// String-shaped types: encode via S7StringCodec then push via WriteBytesAsync. The
|
||||
// codec rejects out-of-range lengths and non-ASCII for CHAR — we let the resulting
|
||||
// ArgumentException bubble out so the WriteAsync caller maps it to BadInternalError.
|
||||
if (tag.DataType is S7DataType.String or S7DataType.WString or S7DataType.Char or S7DataType.WChar)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
if (addr.Size == S7Size.Bit)
|
||||
throw new InvalidOperationException(
|
||||
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as bit-access; string-shaped types require byte-addressing (e.g. DBB / MB / IB / QB)");
|
||||
|
||||
byte[] payload = tag.DataType switch
|
||||
{
|
||||
S7DataType.Char => S7StringCodec.EncodeChar(Convert.ToChar(value ?? throw new ArgumentNullException(nameof(value)))),
|
||||
S7DataType.WChar => S7StringCodec.EncodeWChar(Convert.ToChar(value ?? throw new ArgumentNullException(nameof(value)))),
|
||||
S7DataType.String => S7StringCodec.EncodeString(Convert.ToString(value) ?? string.Empty, tag.StringLength),
|
||||
S7DataType.WString => S7StringCodec.EncodeWString(Convert.ToString(value) ?? string.Empty, tag.StringLength),
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, payload, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Date/time-shaped types: encode via S7DateTimeCodec and push as raw bytes. S5TIME /
|
||||
// TIME / TOD accept an integer-ms input (matching the read surface); DTL / DT / DATE
|
||||
// accept a DateTime. ArgumentException from the codec bubbles to BadInternalError.
|
||||
if (tag.DataType is S7DataType.Dtl or S7DataType.DateAndTime or S7DataType.S5Time
|
||||
or S7DataType.Time or S7DataType.TimeOfDay or S7DataType.Date)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
if (addr.Size == S7Size.Bit)
|
||||
throw new InvalidOperationException(
|
||||
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as bit-access; date/time types require byte-addressing");
|
||||
if (value is null)
|
||||
throw new ArgumentNullException(nameof(value));
|
||||
|
||||
byte[] payload = tag.DataType switch
|
||||
{
|
||||
S7DataType.Dtl => S7DateTimeCodec.EncodeDtl(Convert.ToDateTime(value)),
|
||||
S7DataType.DateAndTime => S7DateTimeCodec.EncodeDt(Convert.ToDateTime(value)),
|
||||
S7DataType.S5Time => S7DateTimeCodec.EncodeS5Time(value is TimeSpan ts1 ? ts1 : TimeSpan.FromMilliseconds(Convert.ToInt32(value))),
|
||||
S7DataType.Time => S7DateTimeCodec.EncodeTime(value is TimeSpan ts2 ? ts2 : TimeSpan.FromMilliseconds(Convert.ToInt32(value))),
|
||||
S7DataType.TimeOfDay => S7DateTimeCodec.EncodeTod(value is TimeSpan ts3 ? ts3 : TimeSpan.FromMilliseconds(Convert.ToInt64(value))),
|
||||
S7DataType.Date => S7DateTimeCodec.EncodeDate(Convert.ToDateTime(value)),
|
||||
_ => throw new InvalidOperationException(),
|
||||
};
|
||||
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, payload, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 64-bit types: S7.Net has no LWord-aware WriteAsync(string, object) overload, so emit
|
||||
// the value as 8 big-endian bytes via WriteBytesAsync. Wire order on S7 is BE so a
|
||||
// BinaryPrimitives.Write*BigEndian round-trips with the matching ReadOneAsync path.
|
||||
if (tag.DataType is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64)
|
||||
{
|
||||
var addr = _parsedByName[tag.Name];
|
||||
if (addr.Size != S7Size.LWord)
|
||||
throw new InvalidOperationException(
|
||||
$"S7 Write type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
|
||||
$"parsed as Size={addr.Size}; 64-bit types require an LD/DBL/DBLD suffix");
|
||||
|
||||
var buf = new byte[8];
|
||||
switch (tag.DataType)
|
||||
{
|
||||
case S7DataType.Int64:
|
||||
BinaryPrimitives.WriteInt64BigEndian(buf, Convert.ToInt64(value));
|
||||
break;
|
||||
case S7DataType.UInt64:
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buf, Convert.ToUInt64(value));
|
||||
break;
|
||||
case S7DataType.Float64:
|
||||
BinaryPrimitives.WriteUInt64BigEndian(buf, BitConverter.DoubleToUInt64Bits(Convert.ToDouble(value)));
|
||||
break;
|
||||
}
|
||||
await plc.WriteBytesAsync(MapArea(addr.Area), addr.DbNumber, addr.ByteOffset, buf, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
|
||||
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
|
||||
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned
|
||||
@@ -313,10 +853,6 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
|
||||
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
|
||||
|
||||
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 writes land in a follow-up PR"),
|
||||
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 writes land in a follow-up PR"),
|
||||
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) writes land in a follow-up PR"),
|
||||
S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"),
|
||||
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
|
||||
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
|
||||
};
|
||||
@@ -334,11 +870,12 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
var folder = builder.Folder("S7", "S7");
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
var isArr = t.ElementCount is int ec && ec > 1;
|
||||
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||||
FullName: t.Name,
|
||||
DriverDataType: MapDataType(t.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
IsArray: isArr,
|
||||
ArrayDim: isArr ? (uint)t.ElementCount!.Value : null,
|
||||
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false,
|
||||
@@ -347,16 +884,198 @@ public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="t"/> can be used as an array element. Variable-width string
|
||||
/// types and BOOL (packed-bit layout) are rejected — both need bespoke addressing
|
||||
/// beyond a flat <c>N × elementBytes</c> byte-range read and ship as a follow-up.
|
||||
/// </summary>
|
||||
internal static bool IsArrayElementSupported(S7DataType t) => t is
|
||||
S7DataType.Byte or
|
||||
S7DataType.Int16 or S7DataType.UInt16 or
|
||||
S7DataType.Int32 or S7DataType.UInt32 or
|
||||
S7DataType.Int64 or S7DataType.UInt64 or
|
||||
S7DataType.Float32 or S7DataType.Float64 or
|
||||
S7DataType.Date or S7DataType.Time or S7DataType.TimeOfDay;
|
||||
|
||||
/// <summary>
|
||||
/// On-wire bytes per array element for the supported fixed-width element types. DATE
|
||||
/// is a 16-bit days-since-1990 counter, TIME and TOD are 32-bit ms counters.
|
||||
/// </summary>
|
||||
internal static int ArrayElementBytes(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.Byte => 1,
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Date => 2,
|
||||
S7DataType.Int32 or S7DataType.UInt32 or S7DataType.Float32
|
||||
or S7DataType.Time or S7DataType.TimeOfDay => 4,
|
||||
S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 => 8,
|
||||
_ => throw new InvalidOperationException($"S7 array element bytes undefined for {t}"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Slice a flat S7 byte buffer into a typed array using the existing big-endian scalar
|
||||
/// codec for each element. Returns the typed array boxed as <c>object</c> so the
|
||||
/// <see cref="DataValueSnapshot"/> surface can carry it without further conversion.
|
||||
/// </summary>
|
||||
internal static object SliceArray(byte[] bytes, S7DataType t, int n, int elemBytes)
|
||||
{
|
||||
switch (t)
|
||||
{
|
||||
case S7DataType.Byte:
|
||||
{
|
||||
var a = new byte[n];
|
||||
Buffer.BlockCopy(bytes, 0, a, 0, n);
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Int16:
|
||||
{
|
||||
var a = new short[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt16BigEndian(bytes.AsSpan(i * elemBytes, 2));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.UInt16:
|
||||
{
|
||||
var a = new ushort[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt16BigEndian(bytes.AsSpan(i * elemBytes, 2));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Int32:
|
||||
{
|
||||
var a = new int[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt32BigEndian(bytes.AsSpan(i * elemBytes, 4));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.UInt32:
|
||||
{
|
||||
var a = new uint[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(i * elemBytes, 4));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Int64:
|
||||
{
|
||||
var a = new long[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadInt64BigEndian(bytes.AsSpan(i * elemBytes, 8));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.UInt64:
|
||||
{
|
||||
var a = new ulong[n];
|
||||
for (var i = 0; i < n; i++) a[i] = BinaryPrimitives.ReadUInt64BigEndian(bytes.AsSpan(i * elemBytes, 8));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Float32:
|
||||
{
|
||||
var a = new float[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
a[i] = BitConverter.UInt32BitsToSingle(BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(i * elemBytes, 4)));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Float64:
|
||||
{
|
||||
var a = new double[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
a[i] = BitConverter.UInt64BitsToDouble(BinaryPrimitives.ReadUInt64BigEndian(bytes.AsSpan(i * elemBytes, 8)));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Date:
|
||||
{
|
||||
var a = new DateTime[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
a[i] = S7DateTimeCodec.DecodeDate(bytes.AsSpan(i * elemBytes, 2));
|
||||
return a;
|
||||
}
|
||||
case S7DataType.Time:
|
||||
{
|
||||
// Surface as Int32 ms — matches the scalar Time read path (driver-specs §5).
|
||||
var a = new int[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
a[i] = (int)S7DateTimeCodec.DecodeTime(bytes.AsSpan(i * elemBytes, 4)).TotalMilliseconds;
|
||||
return a;
|
||||
}
|
||||
case S7DataType.TimeOfDay:
|
||||
{
|
||||
var a = new int[n];
|
||||
for (var i = 0; i < n; i++)
|
||||
a[i] = (int)S7DateTimeCodec.DecodeTod(bytes.AsSpan(i * elemBytes, 4)).TotalMilliseconds;
|
||||
return a;
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"S7 array slice undefined for {t}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pack a caller-supplied array (object) into the on-wire S7 byte layout for
|
||||
/// <paramref name="elementType"/>. Accepts both the strongly-typed array
|
||||
/// (<c>short[]</c>, <c>int[]</c>, ...) and a generic <c>System.Array</c> / <c>IEnumerable</c>
|
||||
/// so OPC UA Variant-boxed values flow through unchanged.
|
||||
/// </summary>
|
||||
internal static byte[] PackArray(object value, S7DataType elementType, int n, int elemBytes, string tagName)
|
||||
{
|
||||
if (value is not System.Collections.IEnumerable enumerable)
|
||||
throw new ArgumentException($"S7 Write tag '{tagName}' is array but value is not enumerable (got {value.GetType().Name})", nameof(value));
|
||||
|
||||
var buf = new byte[n * elemBytes];
|
||||
var i = 0;
|
||||
foreach (var raw in enumerable)
|
||||
{
|
||||
if (i >= n)
|
||||
throw new ArgumentException($"S7 Write tag '{tagName}': value has more than ElementCount={n} elements", nameof(value));
|
||||
var span = buf.AsSpan(i * elemBytes, elemBytes);
|
||||
switch (elementType)
|
||||
{
|
||||
case S7DataType.Byte: span[0] = Convert.ToByte(raw); break;
|
||||
case S7DataType.Int16: BinaryPrimitives.WriteInt16BigEndian(span, Convert.ToInt16(raw)); break;
|
||||
case S7DataType.UInt16: BinaryPrimitives.WriteUInt16BigEndian(span, Convert.ToUInt16(raw)); break;
|
||||
case S7DataType.Int32: BinaryPrimitives.WriteInt32BigEndian(span, Convert.ToInt32(raw)); break;
|
||||
case S7DataType.UInt32: BinaryPrimitives.WriteUInt32BigEndian(span, Convert.ToUInt32(raw)); break;
|
||||
case S7DataType.Int64: BinaryPrimitives.WriteInt64BigEndian(span, Convert.ToInt64(raw)); break;
|
||||
case S7DataType.UInt64: BinaryPrimitives.WriteUInt64BigEndian(span, Convert.ToUInt64(raw)); break;
|
||||
case S7DataType.Float32: BinaryPrimitives.WriteUInt32BigEndian(span, BitConverter.SingleToUInt32Bits(Convert.ToSingle(raw))); break;
|
||||
case S7DataType.Float64: BinaryPrimitives.WriteUInt64BigEndian(span, BitConverter.DoubleToUInt64Bits(Convert.ToDouble(raw))); break;
|
||||
case S7DataType.Date:
|
||||
S7DateTimeCodec.EncodeDate(Convert.ToDateTime(raw)).CopyTo(span);
|
||||
break;
|
||||
case S7DataType.Time:
|
||||
S7DateTimeCodec.EncodeTime(raw is TimeSpan ts ? ts : TimeSpan.FromMilliseconds(Convert.ToInt32(raw))).CopyTo(span);
|
||||
break;
|
||||
case S7DataType.TimeOfDay:
|
||||
S7DateTimeCodec.EncodeTod(raw is TimeSpan tod ? tod : TimeSpan.FromMilliseconds(Convert.ToInt64(raw))).CopyTo(span);
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"S7 array pack undefined for {elementType}");
|
||||
}
|
||||
i++;
|
||||
}
|
||||
if (i != n)
|
||||
throw new ArgumentException($"S7 Write tag '{tagName}': value had {i} elements, expected ElementCount={n}", nameof(value));
|
||||
return buf;
|
||||
}
|
||||
|
||||
private static DriverDataType MapDataType(S7DataType t) => t switch
|
||||
{
|
||||
S7DataType.Bool => DriverDataType.Boolean,
|
||||
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
|
||||
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
|
||||
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1
|
||||
S7DataType.Int16 => DriverDataType.Int16,
|
||||
S7DataType.UInt16 => DriverDataType.UInt16,
|
||||
S7DataType.Int32 => DriverDataType.Int32,
|
||||
S7DataType.UInt32 => DriverDataType.UInt32,
|
||||
S7DataType.Int64 => DriverDataType.Int64,
|
||||
S7DataType.UInt64 => DriverDataType.UInt64,
|
||||
S7DataType.Float32 => DriverDataType.Float32,
|
||||
S7DataType.Float64 => DriverDataType.Float64,
|
||||
S7DataType.String => DriverDataType.String,
|
||||
S7DataType.WString => DriverDataType.String,
|
||||
S7DataType.Char => DriverDataType.String,
|
||||
S7DataType.WChar => DriverDataType.String,
|
||||
S7DataType.DateTime => DriverDataType.DateTime,
|
||||
S7DataType.Dtl => DriverDataType.DateTime,
|
||||
S7DataType.DateAndTime => DriverDataType.DateTime,
|
||||
S7DataType.Date => DriverDataType.DateTime,
|
||||
// S5TIME/TIME/TOD have no Duration type in DriverDataType — surface as Int32 ms
|
||||
// (matching the IEC-1131 representation).
|
||||
S7DataType.S5Time => DriverDataType.Int32,
|
||||
S7DataType.Time => DriverDataType.Int32,
|
||||
S7DataType.TimeOfDay => DriverDataType.Int32,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
|
||||
@@ -63,6 +63,24 @@ public sealed class S7DriverOptions
|
||||
/// Running ↔ Stopped transitions.
|
||||
/// </summary>
|
||||
public S7ProbeOptions Probe { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Block-read coalescing gap-merge threshold (bytes). When two same-DB tags are
|
||||
/// within this many bytes of each other the planner folds them into a single
|
||||
/// <c>Plc.ReadBytesAsync</c> request and slices the response client-side. The
|
||||
/// default <see cref="S7BlockCoalescingPlanner.DefaultGapMergeBytes"/> = 16 bytes
|
||||
/// trades a minor over-fetch for one fewer PDU round-trip — over-fetching 16
|
||||
/// bytes is cheaper than the ~30-byte S7 request frame.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Raise the threshold for chatty PLCs where PDU round-trips dominate latency
|
||||
/// (S7-1200 with default 240-byte PDU); lower it when DBs are sparsely populated
|
||||
/// so the over-fetch cost outweighs the saved PDU. Setting to 0 disables gap
|
||||
/// merging entirely — only literally adjacent ranges (gap == 0) coalesce.
|
||||
/// STRING / WSTRING / CHAR / WCHAR / structured-timestamp / array tags always
|
||||
/// opt out of merging regardless of this knob.
|
||||
/// </remarks>
|
||||
public int BlockCoalescingGapBytes { get; init; } = S7BlockCoalescingPlanner.DefaultGapMergeBytes;
|
||||
}
|
||||
|
||||
public sealed class S7ProbeOptions
|
||||
@@ -95,13 +113,23 @@ public sealed class S7ProbeOptions
|
||||
/// value can be written again without side-effects. Unsafe: M (merker) bits or Q (output)
|
||||
/// coils that drive edge-triggered routines in the PLC program.
|
||||
/// </param>
|
||||
/// <param name="ElementCount">
|
||||
/// Optional 1-D array length. <c>null</c> (or <c>1</c>) = scalar tag; <c>> 1</c> = array.
|
||||
/// The driver issues one byte-range read covering <c>ElementCount × bytes-per-element</c>
|
||||
/// and slices client-side via the existing scalar codec. Multi-dim arrays are deferred;
|
||||
/// array-of-UDT lands with PR-S7-D2. Variable-width element types
|
||||
/// (STRING/WSTRING/CHAR/WCHAR) and BOOL (packed bits) are rejected at init time —
|
||||
/// they need bespoke layout handling and are tracked as a follow-up. Capped at 8000 to
|
||||
/// keep the byte-range request inside a single S7 PDU envelope.
|
||||
/// </param>
|
||||
public sealed record S7TagDefinition(
|
||||
string Name,
|
||||
string Address,
|
||||
S7DataType DataType,
|
||||
bool Writable = true,
|
||||
int StringLength = 254,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
int? ElementCount = null);
|
||||
|
||||
public enum S7DataType
|
||||
{
|
||||
@@ -116,5 +144,23 @@ public enum S7DataType
|
||||
Float32,
|
||||
Float64,
|
||||
String,
|
||||
/// <summary>S7 WSTRING: 4-byte header (max-len + actual-len, both UInt16 big-endian) followed by N×2 UTF-16BE bytes; total wire length = 4 + 2 × StringLength.</summary>
|
||||
WString,
|
||||
/// <summary>S7 CHAR: single ASCII byte.</summary>
|
||||
Char,
|
||||
/// <summary>S7 WCHAR: two bytes UTF-16 big-endian.</summary>
|
||||
WChar,
|
||||
DateTime,
|
||||
/// <summary>S7 DTL — 12-byte structured timestamp with year/mon/day/dow/h/m/s/ns; year range 1970-2554.</summary>
|
||||
Dtl,
|
||||
/// <summary>S7 DATE_AND_TIME (DT) — 8-byte BCD timestamp; year range 1990-2089.</summary>
|
||||
DateAndTime,
|
||||
/// <summary>S7 S5TIME — 16-bit BCD duration with 2-bit timebase; range 0..9990s. Surfaced as Int32 ms.</summary>
|
||||
S5Time,
|
||||
/// <summary>S7 TIME — signed Int32 ms big-endian. Surfaced as Int32 ms (negative durations allowed).</summary>
|
||||
Time,
|
||||
/// <summary>S7 TIME_OF_DAY (TOD) — UInt32 ms since midnight big-endian; range 0..86399999. Surfaced as Int32 ms.</summary>
|
||||
TimeOfDay,
|
||||
/// <summary>S7 DATE — UInt16 days since 1990-01-01 big-endian. Surfaced as DateTime.</summary>
|
||||
Date,
|
||||
}
|
||||
|
||||
190
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadPacker.cs
Normal file
190
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadPacker.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
using S7.Net;
|
||||
using S7.Net.Types;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Multi-variable PDU packer for S7 reads. Replaces the per-tag <c>Plc.ReadAsync</c>
|
||||
/// loop with batched <c>Plc.ReadMultipleVarsAsync</c> calls so that N scalar tags fit
|
||||
/// into ⌈N / 19⌉ PDU round-trips on a default 240-byte negotiated PDU instead of N.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Packing budget</b>: Siemens S7 read response budget is
|
||||
/// <c>negotiatedPduSize - 18 - 12·N</c>, where the 18 bytes cover the response
|
||||
/// header / parameter headers and 12 bytes per item carry the per-variable item
|
||||
/// response (return code + data header + value). For a 240-byte PDU the absolute
|
||||
/// ceiling is ~19 items per request before the response overflows; we apply that
|
||||
/// as a conservative cap regardless of negotiated PDU since S7.Net does not
|
||||
/// expose the negotiated size and 240 is the default for every CPU family.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Packable types only</b>: only fixed-width scalars where the wire layout
|
||||
/// maps 1-to-1 onto an <see cref="VarType"/> the multi-var path natively decodes
|
||||
/// (Bool, Byte, Int16/UInt16, Int32/UInt32, Float32, Float64). Strings, dates,
|
||||
/// arrays, 64-bit ints, and UDT-shaped types stay on the per-tag
|
||||
/// <c>ReadOneAsync</c> path because their decode requires
|
||||
/// <c>Plc.ReadBytesAsync</c> + bespoke codec rather than a single
|
||||
/// <see cref="DataItem"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class S7ReadPacker
|
||||
{
|
||||
/// <summary>
|
||||
/// Default negotiated S7 PDU size (bytes). Every S7 CPU family negotiates 240 by
|
||||
/// default; the extended-PDU 480 / 960 byte settings need an explicit COTP
|
||||
/// parameter that S7.Net does not expose. Stay conservative.
|
||||
/// </summary>
|
||||
internal const int DefaultPduSize = 240;
|
||||
|
||||
/// <summary>
|
||||
/// Per-item response overhead in bytes — return code + data type code + length
|
||||
/// field. The S7 spec calls this 4 bytes minimum but rounds up to 12 once the
|
||||
/// payload alignment + worst-case 8-byte LReal value field are included.
|
||||
/// </summary>
|
||||
internal const int PerItemResponseBytes = 12;
|
||||
|
||||
/// <summary>Fixed response-header bytes regardless of item count.</summary>
|
||||
internal const int ResponseHeaderBytes = 18;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum items per PDU at the default 240-byte negotiated size. Derived from
|
||||
/// <c>floor((240 - 18) / 12) = 18.5</c> rounded down to 18 plus 1 for a
|
||||
/// response-header slack the S7 spec rounds up; the practical Siemens limit
|
||||
/// documented in TIA Portal is 19 items per <c>PUT</c>/<c>GET</c> call so we cap
|
||||
/// at 19 and rely on the budget calculation only when a non-default PDU is in
|
||||
/// play.
|
||||
/// </summary>
|
||||
internal const int MaxItemsPerPdu240 = 19;
|
||||
|
||||
/// <summary>
|
||||
/// Compute how many items can fit in one <c>Plc.ReadMultipleVarsAsync</c>
|
||||
/// call at the given negotiated PDU size, capped at the practical Siemens
|
||||
/// ceiling of 19 items.
|
||||
/// </summary>
|
||||
internal static int ItemBudget(int negotiatedPduSize)
|
||||
{
|
||||
if (negotiatedPduSize <= ResponseHeaderBytes + PerItemResponseBytes)
|
||||
return 1;
|
||||
var byBudget = (negotiatedPduSize - ResponseHeaderBytes) / PerItemResponseBytes;
|
||||
return Math.Min(byBudget, MaxItemsPerPdu240);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if the tag can be packed into a single <see cref="DataItem"/> for
|
||||
/// <c>Plc.ReadMultipleVarsAsync</c>. Returns false for everything that
|
||||
/// needs a custom byte-range decode (strings, dates, arrays, UDTs, 64-bit ints
|
||||
/// where S7.Net's <see cref="VarType"/> has no entry).
|
||||
/// </summary>
|
||||
internal static bool IsPackable(S7TagDefinition tag, S7ParsedAddress addr)
|
||||
{
|
||||
if (tag.ElementCount is int n && n > 1) return false; // arrays go through ReadOneAsync
|
||||
return tag.DataType switch
|
||||
{
|
||||
S7DataType.Bool when addr.Size == S7Size.Bit => true,
|
||||
S7DataType.Byte when addr.Size == S7Size.Byte => true,
|
||||
S7DataType.Int16 or S7DataType.UInt16 when addr.Size == S7Size.Word => true,
|
||||
S7DataType.Int32 or S7DataType.UInt32 when addr.Size == S7Size.DWord => true,
|
||||
S7DataType.Float32 when addr.Size == S7Size.DWord => true,
|
||||
S7DataType.Float64 when addr.Size == S7Size.LWord => true,
|
||||
// Int64 / UInt64 have no native VarType; S7.Net's multi-var path can't decode
|
||||
// them without falling back to byte-range reads. Route to ReadOneAsync.
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="DataItem"/> for a packable tag. <see cref="VarType"/> is
|
||||
/// chosen so that S7.Net's multi-var path decodes the wire bytes into a .NET type
|
||||
/// this driver can reinterpret without a second PLC round-trip
|
||||
/// (Word→ushort, DWord→uint, etc.).
|
||||
/// </summary>
|
||||
internal static DataItem BuildDataItem(S7TagDefinition tag, S7ParsedAddress addr)
|
||||
{
|
||||
var dataType = MapArea(addr.Area);
|
||||
var varType = tag.DataType switch
|
||||
{
|
||||
S7DataType.Bool => VarType.Bit,
|
||||
S7DataType.Byte => VarType.Byte,
|
||||
// Int16 read via Word (UInt16 wire) and reinterpreted to short in
|
||||
// DecodePackedValue; gives identical wire behaviour to the single-tag path.
|
||||
S7DataType.Int16 => VarType.Word,
|
||||
S7DataType.UInt16 => VarType.Word,
|
||||
S7DataType.Int32 => VarType.DWord,
|
||||
S7DataType.UInt32 => VarType.DWord,
|
||||
S7DataType.Float32 => VarType.Real,
|
||||
S7DataType.Float64 => VarType.LReal,
|
||||
_ => throw new InvalidOperationException(
|
||||
$"S7ReadPacker: tag '{tag.Name}' DataType {tag.DataType} is not packable; IsPackable check skipped"),
|
||||
};
|
||||
return new DataItem
|
||||
{
|
||||
DataType = dataType,
|
||||
VarType = varType,
|
||||
DB = addr.DbNumber,
|
||||
StartByteAdr = addr.ByteOffset,
|
||||
BitAdr = (byte)addr.BitOffset,
|
||||
Count = 1,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert the boxed value S7.Net's multi-var path returns into the .NET type
|
||||
/// declared by <paramref name="tag"/>. Mirrors the reinterpret table in
|
||||
/// <c>S7Driver.ReadOneAsync</c> so packed reads and single-tag reads produce
|
||||
/// identical snapshots for the same input.
|
||||
/// </summary>
|
||||
internal static object DecodePackedValue(S7TagDefinition tag, object raw)
|
||||
{
|
||||
return (tag.DataType, raw) switch
|
||||
{
|
||||
(S7DataType.Bool, bool b) => b,
|
||||
(S7DataType.Byte, byte by) => by,
|
||||
(S7DataType.UInt16, ushort u16) => u16,
|
||||
(S7DataType.Int16, ushort u16) => unchecked((short)u16),
|
||||
(S7DataType.UInt32, uint u32) => u32,
|
||||
(S7DataType.Int32, uint u32) => unchecked((int)u32),
|
||||
(S7DataType.Float32, float f) => f,
|
||||
(S7DataType.Float64, double d) => d,
|
||||
// S7.Net occasionally hands back the underlying integer type for Real/LReal
|
||||
// when the bytes were marshalled raw — reinterpret defensively.
|
||||
(S7DataType.Float32, uint u32) => BitConverter.UInt32BitsToSingle(u32),
|
||||
(S7DataType.Float64, ulong u64) => BitConverter.UInt64BitsToDouble(u64),
|
||||
_ => throw new System.IO.InvalidDataException(
|
||||
$"S7ReadPacker: tag '{tag.Name}' declared {tag.DataType} but multi-var returned {raw.GetType().Name}"),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bin-pack <paramref name="indices"/> into batches of at most
|
||||
/// <paramref name="itemBudget"/> items. Order within each batch matches the
|
||||
/// input order so the per-item response from S7.Net maps back 1-to-1.
|
||||
/// </summary>
|
||||
internal static List<List<int>> BinPack(IReadOnlyList<int> indices, int itemBudget)
|
||||
{
|
||||
var batches = new List<List<int>>();
|
||||
var current = new List<int>(itemBudget);
|
||||
foreach (var idx in indices)
|
||||
{
|
||||
current.Add(idx);
|
||||
if (current.Count >= itemBudget)
|
||||
{
|
||||
batches.Add(current);
|
||||
current = new List<int>(itemBudget);
|
||||
}
|
||||
}
|
||||
if (current.Count > 0) batches.Add(current);
|
||||
return batches;
|
||||
}
|
||||
|
||||
private static DataType MapArea(S7Area area) => area switch
|
||||
{
|
||||
S7Area.DataBlock => DataType.DataBlock,
|
||||
S7Area.Memory => DataType.Memory,
|
||||
S7Area.Input => DataType.Input,
|
||||
S7Area.Output => DataType.Output,
|
||||
S7Area.Timer => DataType.Timer,
|
||||
S7Area.Counter => DataType.Counter,
|
||||
_ => throw new InvalidOperationException($"Unknown S7Area {area}"),
|
||||
};
|
||||
}
|
||||
166
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7StringCodec.cs
Normal file
166
src/ZB.MOM.WW.OtOpcUa.Driver.S7/S7StringCodec.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
|
||||
|
||||
/// <summary>
|
||||
/// Byte-level codecs for the four Siemens S7 string-shaped types: STRING, WSTRING,
|
||||
/// CHAR, WCHAR. Pulled out of <see cref="S7Driver"/> so the encoding rules are
|
||||
/// unit-testable against golden byte vectors without standing up a Plc instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Wire formats (all big-endian, matching S7's native byte order):
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>STRING</b>: 2-byte header (<c>maxLen</c> byte, <c>actualLen</c> byte) +
|
||||
/// N ASCII bytes. Total slot size on the PLC = <c>2 + maxLen</c>. Bytes past
|
||||
/// <c>actualLen</c> are unspecified — the codec ignores them on read.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>WSTRING</b>: 4-byte header (<c>maxLen</c> UInt16 BE, <c>actualLen</c>
|
||||
/// UInt16 BE) + N × 2 UTF-16BE bytes. Total slot size on the PLC =
|
||||
/// <c>4 + 2 × maxLen</c>.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>CHAR</b>: 1 ASCII byte.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>WCHAR</b>: 2 UTF-16BE bytes.
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// <b>Header-bug clamp</b>: certain S7 firmware revisions write
|
||||
/// <c>actualLen > maxLen</c> (observed with NULL-padded buffers from older
|
||||
/// CP-modules). On <i>read</i> the codec clamps the effective length so it never
|
||||
/// walks past the wire buffer. On <i>write</i> the codec rejects the input
|
||||
/// outright — silently truncating produces silent data loss.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class S7StringCodec
|
||||
{
|
||||
/// <summary>Buffer size for a STRING tag with the given declared <paramref name="maxLen"/>.</summary>
|
||||
public static int StringBufferSize(int maxLen) => 2 + maxLen;
|
||||
|
||||
/// <summary>Buffer size for a WSTRING tag with the given declared <paramref name="maxLen"/>.</summary>
|
||||
public static int WStringBufferSize(int maxLen) => 4 + (2 * maxLen);
|
||||
|
||||
/// <summary>
|
||||
/// Decode an S7 STRING wire buffer into a .NET string. <paramref name="bytes"/>
|
||||
/// must be exactly <c>2 + maxLen</c> long. <c>actualLen</c> is clamped to the
|
||||
/// declared <paramref name="maxLen"/> if the firmware reported an out-of-spec
|
||||
/// value (header-bug tolerance).
|
||||
/// </summary>
|
||||
public static string DecodeString(ReadOnlySpan<byte> bytes, int maxLen)
|
||||
{
|
||||
if (maxLen is < 1 or > 254)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 STRING max length must be 1-254");
|
||||
var expected = StringBufferSize(maxLen);
|
||||
if (bytes.Length != expected)
|
||||
throw new InvalidDataException($"S7 STRING expected {expected} bytes, got {bytes.Length}");
|
||||
|
||||
// bytes[0] = declared max-length (advisory; we trust the caller-provided maxLen).
|
||||
// bytes[1] = actual length. Clamp on read — firmware bug fallback.
|
||||
int actual = bytes[1];
|
||||
if (actual > maxLen) actual = maxLen;
|
||||
if (actual == 0) return string.Empty;
|
||||
return Encoding.ASCII.GetString(bytes.Slice(2, actual));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode a .NET string into an S7 STRING wire buffer of length
|
||||
/// <c>2 + maxLen</c>. ASCII only — non-ASCII characters are encoded as <c>?</c>
|
||||
/// by <see cref="Encoding.ASCII"/>. Throws if <paramref name="value"/> is longer
|
||||
/// than <paramref name="maxLen"/>.
|
||||
/// </summary>
|
||||
public static byte[] EncodeString(string value, int maxLen)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
if (maxLen is < 1 or > 254)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 STRING max length must be 1-254");
|
||||
if (value.Length > maxLen)
|
||||
throw new ArgumentException(
|
||||
$"S7 STRING value of length {value.Length} exceeds declared max {maxLen}", nameof(value));
|
||||
|
||||
var buf = new byte[StringBufferSize(maxLen)];
|
||||
buf[0] = (byte)maxLen;
|
||||
buf[1] = (byte)value.Length;
|
||||
Encoding.ASCII.GetBytes(value, 0, value.Length, buf, 2);
|
||||
// Trailing bytes [2 + value.Length .. end] left as 0x00; S7 PLCs treat them as
|
||||
// don't-care because actualLen bounds the readable region.
|
||||
return buf;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decode an S7 WSTRING wire buffer into a .NET string. <paramref name="bytes"/>
|
||||
/// must be exactly <c>4 + 2 × maxLen</c> long. <c>actualLen</c> is clamped to
|
||||
/// <paramref name="maxLen"/> on read.
|
||||
/// </summary>
|
||||
public static string DecodeWString(ReadOnlySpan<byte> bytes, int maxLen)
|
||||
{
|
||||
if (maxLen < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 WSTRING max length must be >= 1");
|
||||
var expected = WStringBufferSize(maxLen);
|
||||
if (bytes.Length != expected)
|
||||
throw new InvalidDataException($"S7 WSTRING expected {expected} bytes, got {bytes.Length}");
|
||||
|
||||
// Header is two UInt16 BE: declared max-len and actual-len (both in characters).
|
||||
int actual = BinaryPrimitives.ReadUInt16BigEndian(bytes.Slice(2, 2));
|
||||
if (actual > maxLen) actual = maxLen;
|
||||
if (actual == 0) return string.Empty;
|
||||
return Encoding.BigEndianUnicode.GetString(bytes.Slice(4, actual * 2));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encode a .NET string into an S7 WSTRING wire buffer of length
|
||||
/// <c>4 + 2 × maxLen</c>. Throws if <paramref name="value"/> has more than
|
||||
/// <paramref name="maxLen"/> UTF-16 code units.
|
||||
/// </summary>
|
||||
public static byte[] EncodeWString(string value, int maxLen)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
if (maxLen < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxLen), maxLen, "S7 WSTRING max length must be >= 1");
|
||||
if (value.Length > maxLen)
|
||||
throw new ArgumentException(
|
||||
$"S7 WSTRING value of length {value.Length} exceeds declared max {maxLen}", nameof(value));
|
||||
|
||||
var buf = new byte[WStringBufferSize(maxLen)];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(0, 2), (ushort)maxLen);
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf.AsSpan(2, 2), (ushort)value.Length);
|
||||
if (value.Length > 0)
|
||||
Encoding.BigEndianUnicode.GetBytes(value, 0, value.Length, buf, 4);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/// <summary>Decode a single S7 CHAR (one ASCII byte).</summary>
|
||||
public static char DecodeChar(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != 1)
|
||||
throw new InvalidDataException($"S7 CHAR expected 1 byte, got {bytes.Length}");
|
||||
return (char)bytes[0];
|
||||
}
|
||||
|
||||
/// <summary>Encode a single ASCII char into an S7 CHAR (one byte). Non-ASCII rejected.</summary>
|
||||
public static byte[] EncodeChar(char value)
|
||||
{
|
||||
if (value > 0x7F)
|
||||
throw new ArgumentException($"S7 CHAR value '{value}' (U+{(int)value:X4}) is not ASCII", nameof(value));
|
||||
return [(byte)value];
|
||||
}
|
||||
|
||||
/// <summary>Decode a single S7 WCHAR (two bytes UTF-16 big-endian).</summary>
|
||||
public static char DecodeWChar(ReadOnlySpan<byte> bytes)
|
||||
{
|
||||
if (bytes.Length != 2)
|
||||
throw new InvalidDataException($"S7 WCHAR expected 2 bytes, got {bytes.Length}");
|
||||
return (char)BinaryPrimitives.ReadUInt16BigEndian(bytes);
|
||||
}
|
||||
|
||||
/// <summary>Encode a single char into an S7 WCHAR (two bytes UTF-16 big-endian).</summary>
|
||||
public static byte[] EncodeWChar(char value)
|
||||
{
|
||||
var buf = new byte[2];
|
||||
BinaryPrimitives.WriteUInt16BigEndian(buf, value);
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Tests"/>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text;
|
||||
using TwinCAT;
|
||||
using TwinCAT.Ads;
|
||||
using TwinCAT.Ads.SumCommand;
|
||||
using TwinCAT.Ads.TypeSystem;
|
||||
using TwinCAT.TypeSystem;
|
||||
|
||||
@@ -24,6 +26,11 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
private readonly AdsClient _client = new();
|
||||
private readonly ConcurrentDictionary<uint, NotificationRegistration> _notifications = new();
|
||||
|
||||
// Per-parent-symbol RMW locks. Keys are bounded by the writable-bit-tag cardinality
|
||||
// and are intentionally never removed — a leaking-but-bounded dictionary is simpler
|
||||
// than tracking liveness, matching the AbCip / Modbus / FOCAS pattern from #181.
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _bitWriteLocks = new();
|
||||
|
||||
public AdsTwinCATClient()
|
||||
{
|
||||
_client.AdsNotificationEx += OnAdsNotificationEx;
|
||||
@@ -44,20 +51,30 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int[]? arrayDimensions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var clrType = MapToClrType(type);
|
||||
var result = await _client.ReadValueAsync(symbolPath, clrType, cancellationToken)
|
||||
var readType = IsWholeArray(arrayDimensions) ? clrType.MakeArrayType() : clrType;
|
||||
|
||||
var result = await _client.ReadValueAsync(symbolPath, readType, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.ErrorCode != AdsErrorCode.NoError)
|
||||
return (null, TwinCATStatusMapper.MapAdsError((uint)result.ErrorCode));
|
||||
|
||||
var value = result.Value;
|
||||
if (IsWholeArray(arrayDimensions))
|
||||
{
|
||||
value = PostProcessArray(type, value);
|
||||
return (value, TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
value = PostProcessIecTime(type, value);
|
||||
|
||||
return (value, TwinCATStatusMapper.Good);
|
||||
}
|
||||
@@ -67,16 +84,43 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsWholeArray(int[]? arrayDimensions) =>
|
||||
arrayDimensions is { Length: > 0 } && arrayDimensions.All(d => d > 0);
|
||||
|
||||
/// <summary>Apply per-element IEC TIME/DATE post-processing to a flat array result.</summary>
|
||||
private static object? PostProcessArray(TwinCATDataType type, object? value)
|
||||
{
|
||||
if (value is not Array arr) return value;
|
||||
var elementProjector = type switch
|
||||
{
|
||||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay
|
||||
or TwinCATDataType.Date or TwinCATDataType.DateTime
|
||||
=> (Func<object?, object?>)(v => PostProcessIecTime(type, v)),
|
||||
_ => null,
|
||||
};
|
||||
if (elementProjector is null) return arr;
|
||||
// IEC time post-processing changes the CLR element type (uint -> TimeSpan / DateTime).
|
||||
// Project into an object[] so the array element type matches the projected values.
|
||||
var projected = new object?[arr.Length];
|
||||
for (var i = 0; i < arr.Length; i++)
|
||||
projected[i] = elementProjector(arr.GetValue(i));
|
||||
return projected;
|
||||
}
|
||||
|
||||
public async Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int[]? arrayDimensions,
|
||||
object? value,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (bitIndex is int && type == TwinCATDataType.Bool)
|
||||
throw new NotSupportedException(
|
||||
"BOOL-within-word writes require read-modify-write; tracked in task #181.");
|
||||
if (IsWholeArray(arrayDimensions))
|
||||
return TwinCATStatusMapper.BadNotSupported; // PR-1.4 ships read-only whole-array
|
||||
|
||||
if (bitIndex is int bit && type == TwinCATDataType.Bool)
|
||||
return await WriteBitInWordAsync(symbolPath, bit, value, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -93,6 +137,69 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-modify-write a single bit within an integer parent word. <paramref name="symbolPath"/>
|
||||
/// is the bit-selector path (e.g. <c>Flags.3</c>); the parent is the same path with the
|
||||
/// <c>.N</c> suffix stripped and is read/written as a UDINT — TwinCAT handles narrower
|
||||
/// parents (BYTE/WORD) implicitly through the UDINT projection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Concurrent bit writers against the same parent are serialised through a per-parent
|
||||
/// <see cref="SemaphoreSlim"/> to prevent torn reads/writes. Mirrors the AbCip / Modbus /
|
||||
/// FOCAS bit-RMW pattern.
|
||||
/// </remarks>
|
||||
private async Task<uint> WriteBitInWordAsync(
|
||||
string symbolPath, int bit, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
var parentPath = TryGetParentSymbolPath(symbolPath);
|
||||
if (parentPath is null) return TwinCATStatusMapper.BadNotSupported;
|
||||
|
||||
var setBit = Convert.ToBoolean(value);
|
||||
var rmwLock = _bitWriteLocks.GetOrAdd(parentPath, _ => new SemaphoreSlim(1, 1));
|
||||
await rmwLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var read = await _client.ReadValueAsync(parentPath, typeof(uint), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read.ErrorCode != AdsErrorCode.NoError)
|
||||
return TwinCATStatusMapper.MapAdsError((uint)read.ErrorCode);
|
||||
|
||||
var current = Convert.ToUInt32(read.Value ?? 0u);
|
||||
var updated = ApplyBit(current, bit, setBit);
|
||||
|
||||
var write = await _client.WriteValueAsync(parentPath, updated, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return write.ErrorCode == AdsErrorCode.NoError
|
||||
? TwinCATStatusMapper.Good
|
||||
: TwinCATStatusMapper.MapAdsError((uint)write.ErrorCode);
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
return TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||||
}
|
||||
finally
|
||||
{
|
||||
rmwLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strip the trailing <c>.N</c> bit selector from a TwinCAT symbol path. Returns
|
||||
/// <c>null</c> when the path has no parent (single segment / leading dot).
|
||||
/// </summary>
|
||||
internal static string? TryGetParentSymbolPath(string symbolPath)
|
||||
{
|
||||
var dot = symbolPath.LastIndexOf('.');
|
||||
return dot <= 0 ? null : symbolPath.Substring(0, dot);
|
||||
}
|
||||
|
||||
/// <summary>Set or clear bit <paramref name="bit"/> in <paramref name="word"/>.</summary>
|
||||
internal static uint ApplyBit(uint word, int bit, bool setBit)
|
||||
{
|
||||
var mask = 1u << bit;
|
||||
return setBit ? (word | mask) : (word & ~mask);
|
||||
}
|
||||
|
||||
public async Task<bool> ProbeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
@@ -143,6 +250,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
var value = args.Value;
|
||||
if (reg.BitIndex is int bit && reg.Type == TwinCATDataType.Bool && value is not bool)
|
||||
value = ExtractBit(value, bit);
|
||||
value = PostProcessIecTime(reg.Type, value);
|
||||
try { reg.OnChange(reg.SymbolPath, value); } catch { /* consumer-side errors don't crash the ADS thread */ }
|
||||
}
|
||||
|
||||
@@ -166,12 +274,50 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
foreach (ISymbol symbol in loader.Symbols)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) yield break;
|
||||
var mapped = MapSymbolTypeName(symbol.DataType?.Name);
|
||||
var mapped = ResolveSymbolDataType(symbol.DataType);
|
||||
var readOnly = !IsSymbolWritable(symbol);
|
||||
yield return new TwinCATDiscoveredSymbol(symbol.InstancePath, mapped, readOnly);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an IEC atomic <see cref="TwinCATDataType"/> for a TwinCAT symbol's data type.
|
||||
/// ENUMs surface as their underlying integer (the enum's <c>BaseType</c>); ALIAS chains
|
||||
/// are walked recursively via <see cref="IAliasType.BaseType"/> until an atomic primitive
|
||||
/// is reached. POINTER / REFERENCE / INTERFACE / UNION / STRUCT / FB / array types remain
|
||||
/// out of scope and surface as <c>null</c> so the caller skips them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Recursion is bounded at <see cref="MaxAliasDepth"/> as a defence against pathological
|
||||
/// cycles in the type graph — TwinCAT shouldn't emit those, but this is cheap insurance.
|
||||
/// </remarks>
|
||||
internal const int MaxAliasDepth = 16;
|
||||
|
||||
internal static TwinCATDataType? ResolveSymbolDataType(IDataType? dataType)
|
||||
{
|
||||
var current = dataType;
|
||||
for (var depth = 0; current is not null && depth < MaxAliasDepth; depth++)
|
||||
{
|
||||
switch (current.Category)
|
||||
{
|
||||
case DataTypeCategory.Primitive:
|
||||
case DataTypeCategory.String:
|
||||
return MapSymbolTypeName(current.Name);
|
||||
case DataTypeCategory.Enum:
|
||||
case DataTypeCategory.Alias:
|
||||
// IEnumType : IAliasType, so BaseType walk handles both. For an enum the
|
||||
// base type is the underlying integer; for alias chains it's the next link.
|
||||
if (current is IAliasType alias) { current = alias.BaseType; continue; }
|
||||
return null;
|
||||
default:
|
||||
// POINTER / REFERENCE / INTERFACE / UNION / STRUCT / ARRAY / FB / Program —
|
||||
// explicitly out of scope at this PR.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TwinCATDataType? MapSymbolTypeName(string? typeName) => typeName switch
|
||||
{
|
||||
"BOOL" or "BIT" => TwinCATDataType.Bool,
|
||||
@@ -203,6 +349,111 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
|
||||
IReadOnlyList<TwinCATBulkReadItem> reads, CancellationToken cancellationToken)
|
||||
{
|
||||
if (reads.Count == 0) return Array.Empty<(object?, uint)>();
|
||||
|
||||
// Build the (path, AnyTypeSpecifier) request envelope. SumInstancePathAnyTypeRead
|
||||
// batches all paths into a single ADS Sum-read round-trip (IndexGroup 0xF080 = read
|
||||
// multiple items by symbol name with ANY-type marshalling).
|
||||
var typeSpecs = new List<(string instancePath, AnyTypeSpecifier spec)>(reads.Count);
|
||||
foreach (var r in reads)
|
||||
typeSpecs.Add((r.SymbolPath, BuildAnyTypeSpecifier(r.Type, r.StringLength)));
|
||||
|
||||
var sumCmd = new SumInstancePathAnyTypeRead(_client, typeSpecs);
|
||||
|
||||
try
|
||||
{
|
||||
var sumResult = await sumCmd.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// ResultSumValues2.ValueResults is a per-item array with Source / Value /
|
||||
// ErrorCode. Even when the overall ADS request succeeds, individual sub-items can
|
||||
// carry their own ADS error (e.g. SymbolNotFound).
|
||||
var output = new (object? value, uint status)[reads.Count];
|
||||
var valueResults = sumResult.ValueResults;
|
||||
for (var i = 0; i < reads.Count; i++)
|
||||
{
|
||||
var vr = valueResults[i];
|
||||
if (vr.ErrorCode != 0)
|
||||
{
|
||||
output[i] = (null, TwinCATStatusMapper.MapAdsError((uint)vr.ErrorCode));
|
||||
continue;
|
||||
}
|
||||
var raw = vr.Value;
|
||||
output[i] = (PostProcessIecTime(reads[i].Type, raw), TwinCATStatusMapper.Good);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
// Whole-batch failure (no symbol-server ack, router unreachable, etc.). Map the
|
||||
// overall ADS status onto every entry so callers see uniform status — partial-
|
||||
// success marshalling lives in the success branch above.
|
||||
var status = TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||||
var failed = new (object? value, uint status)[reads.Count];
|
||||
for (var i = 0; i < reads.Count; i++) failed[i] = (null, status);
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<uint>> WriteValuesAsync(
|
||||
IReadOnlyList<TwinCATBulkWriteItem> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
if (writes.Count == 0) return Array.Empty<uint>();
|
||||
|
||||
// SumWriteBySymbolPath internally requests symbol handles + issues a single sum-write
|
||||
// (IndexGroup 0xF081) carrying all values. One AMS round-trip for N writes.
|
||||
var paths = new List<string>(writes.Count);
|
||||
var values = new object[writes.Count];
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
paths.Add(writes[i].SymbolPath);
|
||||
values[i] = ConvertForWrite(writes[i].Type, writes[i].Value);
|
||||
}
|
||||
|
||||
var sumCmd = new SumWriteBySymbolPath(_client, paths);
|
||||
|
||||
try
|
||||
{
|
||||
var result = await sumCmd.WriteAsync(values, cancellationToken).ConfigureAwait(false);
|
||||
var output = new uint[writes.Count];
|
||||
var subErrors = result.SubErrors;
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
// SubErrors can be null when the overall request failed before sub-dispatch —
|
||||
// surface the OverallError on every slot in that case.
|
||||
var code = subErrors is { Length: > 0 } && i < subErrors.Length
|
||||
? (uint)subErrors[i]
|
||||
: (uint)result.ErrorCode;
|
||||
output[i] = TwinCATStatusMapper.MapAdsError(code);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
catch (AdsErrorException ex)
|
||||
{
|
||||
var status = TwinCATStatusMapper.MapAdsError((uint)ex.ErrorCode);
|
||||
var failed = new uint[writes.Count];
|
||||
for (var i = 0; i < writes.Count; i++) failed[i] = status;
|
||||
return failed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build an <see cref="AnyTypeSpecifier"/> for one bulk-read entry. STRING uses ASCII +
|
||||
/// the supplied <paramref name="stringLength"/>; WSTRING uses Unicode (UTF-16). All other
|
||||
/// types resolve to a primitive CLR type via <see cref="MapToClrType"/>. IEC time/date
|
||||
/// symbols flow as their underlying UDINT (matching the per-tag path in
|
||||
/// <see cref="ReadValueAsync"/>) and are post-processed CLR-side after the sum-read.
|
||||
/// </summary>
|
||||
private static AnyTypeSpecifier BuildAnyTypeSpecifier(TwinCATDataType type, int stringLength) =>
|
||||
type switch
|
||||
{
|
||||
TwinCATDataType.String => new AnyTypeSpecifier(typeof(string), stringLength, Encoding.ASCII),
|
||||
TwinCATDataType.WString => new AnyTypeSpecifier(typeof(string), stringLength, Encoding.Unicode),
|
||||
_ => new AnyTypeSpecifier(MapToClrType(type)),
|
||||
};
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_client.AdsNotificationEx -= OnAdsNotificationEx;
|
||||
@@ -249,7 +500,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
_ => typeof(int),
|
||||
};
|
||||
|
||||
private static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
|
||||
internal static object ConvertForWrite(TwinCATDataType type, object? value) => type switch
|
||||
{
|
||||
TwinCATDataType.Bool => Convert.ToBoolean(value),
|
||||
TwinCATDataType.SInt => Convert.ToSByte(value),
|
||||
@@ -263,11 +514,79 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
|
||||
TwinCATDataType.Real => Convert.ToSingle(value),
|
||||
TwinCATDataType.LReal => Convert.ToDouble(value),
|
||||
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
|
||||
TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => Convert.ToUInt32(value),
|
||||
// IEC durations (TIME / TOD) accept TimeSpan / Duration-as-Double-ms / raw UDINT.
|
||||
// IEC timestamps (DATE / DT) accept DateTime (UTC) / raw UDINT seconds-since-epoch.
|
||||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DurationToUDInt(value),
|
||||
TwinCATDataType.Date or TwinCATDataType.DateTime => DateTimeToUDInt(value),
|
||||
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
|
||||
};
|
||||
|
||||
// IEC 61131-3 epoch is 1970-01-01 UTC for DATE / DT; TIME / TOD are unsigned ms counters.
|
||||
private static readonly DateTime IecEpochUtc = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
/// <summary>
|
||||
/// Convert the raw UDINT wire value for IEC TIME/DATE/DT/TOD into the native CLR type
|
||||
/// surfaced upstream — TimeSpan for durations, DateTime (UTC) for timestamps. Other
|
||||
/// types pass through unchanged.
|
||||
/// </summary>
|
||||
internal static object? PostProcessIecTime(TwinCATDataType type, object? value)
|
||||
{
|
||||
if (value is null) return null;
|
||||
var raw = TryGetUInt32(value);
|
||||
if (raw is null) return value;
|
||||
return type switch
|
||||
{
|
||||
// TIME / TOD — UDINT milliseconds.
|
||||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay
|
||||
=> TimeSpan.FromMilliseconds(raw.Value),
|
||||
// DT — UDINT seconds since 1970-01-01 UTC.
|
||||
TwinCATDataType.DateTime
|
||||
=> IecEpochUtc.AddSeconds(raw.Value),
|
||||
// DATE — UDINT seconds since 1970-01-01 UTC, but TwinCAT runtimes pin the time
|
||||
// component to midnight; pass through the same conversion so we get a date-only
|
||||
// value at midnight UTC.
|
||||
TwinCATDataType.Date
|
||||
=> IecEpochUtc.AddSeconds(raw.Value),
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
|
||||
private static uint? TryGetUInt32(object value) => value switch
|
||||
{
|
||||
uint u => u,
|
||||
int i when i >= 0 => (uint)i,
|
||||
ushort us => (uint)us,
|
||||
short s when s >= 0 => (uint)s,
|
||||
long l when l >= 0 && l <= uint.MaxValue => (uint)l,
|
||||
ulong ul when ul <= uint.MaxValue => (uint)ul,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static uint DurationToUDInt(object? value) => value switch
|
||||
{
|
||||
TimeSpan ts => (uint)Math.Max(0, ts.TotalMilliseconds),
|
||||
// OPC UA Duration on the wire is a Double in milliseconds.
|
||||
double d => (uint)Math.Max(0, d),
|
||||
float f => (uint)Math.Max(0, f),
|
||||
_ => Convert.ToUInt32(value),
|
||||
};
|
||||
|
||||
private static uint DateTimeToUDInt(object? value)
|
||||
{
|
||||
if (value is DateTime dt)
|
||||
{
|
||||
var utc = dt.Kind == DateTimeKind.Unspecified
|
||||
? DateTime.SpecifyKind(dt, DateTimeKind.Utc)
|
||||
: dt.ToUniversalTime();
|
||||
var seconds = (long)(utc - IecEpochUtc).TotalSeconds;
|
||||
if (seconds < 0 || seconds > uint.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(value),
|
||||
"DATE/DT value out of UDINT epoch range (1970-01-01..2106-02-07 UTC).");
|
||||
return (uint)seconds;
|
||||
}
|
||||
return Convert.ToUInt32(value);
|
||||
}
|
||||
|
||||
private static bool ExtractBit(object? rawWord, int bit) => rawWord switch
|
||||
{
|
||||
short s => (s & (1 << bit)) != 0,
|
||||
|
||||
@@ -22,25 +22,64 @@ public interface ITwinCATClient : IDisposable
|
||||
/// <summary>
|
||||
/// Read a symbolic value. Returns a boxed .NET value matching the requested
|
||||
/// <paramref name="type"/>, or <c>null</c> when the read produced no data; the
|
||||
/// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good).
|
||||
/// <c>status</c> tuple member carries the mapped OPC UA status (0 = Good). When
|
||||
/// <paramref name="arrayDimensions"/> is non-null + non-empty, the symbol is treated
|
||||
/// as a whole-array read and the boxed value is a flat 1-D CLR
|
||||
/// <see cref="Array"/> sized to <c>product(arrayDimensions)</c>.
|
||||
/// </summary>
|
||||
Task<(object? value, uint status)> ReadValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int[]? arrayDimensions,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
|
||||
/// (0 = Good, non-zero = error mapped via <see cref="TwinCATStatusMapper"/>).
|
||||
/// <paramref name="arrayDimensions"/> mirrors <see cref="ReadValueAsync"/>; PR-1.4
|
||||
/// ships read-only whole-array support so writers may surface <c>BadNotSupported</c>.
|
||||
/// </summary>
|
||||
Task<uint> WriteValueAsync(
|
||||
string symbolPath,
|
||||
TwinCATDataType type,
|
||||
int? bitIndex,
|
||||
int[]? arrayDimensions,
|
||||
object? value,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-read N scalar symbols in a single AMS request via Beckhoff's ADS Sum-command
|
||||
/// family (IndexGroup <c>0xF080..0xF084</c>). The result is a parallel array preserving
|
||||
/// <paramref name="reads"/> ordering — element <c>i</c>'s outcome maps to request <c>i</c>.
|
||||
/// Empty input returns an empty result without a wire round-trip.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>This is the throughput-optimised path used by <see cref="TwinCATDriver.ReadAsync"/>
|
||||
/// to replace the per-tag <see cref="ReadValueAsync"/> loop — one ADS sum-read for N
|
||||
/// symbols beats N individual round-trips by ~10× on the typical PLC link.</para>
|
||||
///
|
||||
/// <para>Whole-array reads + bit-extracted BOOL reads stay on the per-tag path because
|
||||
/// the Sum-command surface only marshals scalars + bitIndex needs CLR-side post-processing.
|
||||
/// Callers should pre-filter or fall back as appropriate.</para>
|
||||
/// </remarks>
|
||||
Task<IReadOnlyList<(object? value, uint status)>> ReadValuesAsync(
|
||||
IReadOnlyList<TwinCATBulkReadItem> reads,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Bulk-write N scalar symbols in a single AMS request via Beckhoff's
|
||||
/// <c>SumWriteBySymbolPath</c>. Result is a parallel status array preserving
|
||||
/// <paramref name="writes"/> ordering. Empty input returns an empty result.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Whole-array writes + bit-RMW writes are not in scope for the bulk path — those continue
|
||||
/// through the per-tag <see cref="WriteValueAsync"/> path. The driver layer pre-filters.
|
||||
/// </remarks>
|
||||
Task<IReadOnlyList<uint>> WriteValuesAsync(
|
||||
IReadOnlyList<TwinCATBulkWriteItem> writes,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Cheap health probe — returns <c>true</c> when the target's AMS state is reachable.
|
||||
/// Used by <see cref="Core.Abstractions.IHostConnectivityProbe"/>'s probe loop.
|
||||
@@ -98,3 +137,19 @@ public interface ITwinCATClientFactory
|
||||
{
|
||||
ITwinCATClient Create();
|
||||
}
|
||||
|
||||
/// <summary>One element of an <see cref="ITwinCATClient.ReadValuesAsync"/> request — the symbol path
|
||||
/// + the IEC type for marshalling. Strings carry an explicit <paramref name="StringLength"/> for
|
||||
/// fixed-size <c>STRING(n)</c> declarations (defaults to <c>80</c> matching IEC 61131-3).</summary>
|
||||
public sealed record TwinCATBulkReadItem(
|
||||
string SymbolPath,
|
||||
TwinCATDataType Type,
|
||||
int StringLength = 80);
|
||||
|
||||
/// <summary>One element of an <see cref="ITwinCATClient.WriteValuesAsync"/> request.
|
||||
/// Mirror of <see cref="TwinCATBulkReadItem"/> with the value to push.</summary>
|
||||
public sealed record TwinCATBulkWriteItem(
|
||||
string SymbolPath,
|
||||
TwinCATDataType Type,
|
||||
object? Value,
|
||||
int StringLength = 80);
|
||||
|
||||
@@ -37,12 +37,16 @@ public static class TwinCATDataTypeExtensions
|
||||
TwinCATDataType.SInt or TwinCATDataType.USInt
|
||||
or TwinCATDataType.Int or TwinCATDataType.UInt
|
||||
or TwinCATDataType.DInt or TwinCATDataType.UDInt => DriverDataType.Int32,
|
||||
TwinCATDataType.LInt or TwinCATDataType.ULInt => DriverDataType.Int32, // matches Int64 gap
|
||||
TwinCATDataType.LInt => DriverDataType.Int64,
|
||||
TwinCATDataType.ULInt => DriverDataType.UInt64,
|
||||
TwinCATDataType.Real => DriverDataType.Float32,
|
||||
TwinCATDataType.LReal => DriverDataType.Float64,
|
||||
TwinCATDataType.String or TwinCATDataType.WString => DriverDataType.String,
|
||||
TwinCATDataType.Time or TwinCATDataType.Date
|
||||
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => DriverDataType.Int32,
|
||||
// IEC 61131-3 TIME / TOD are durations (ms); DATE / DT are absolute timestamps.
|
||||
// The wire form is UDINT but the driver post-processes into TimeSpan / DateTime so the
|
||||
// address space surfaces native UA Duration / DateTime instead of opaque integers.
|
||||
TwinCATDataType.Time or TwinCATDataType.TimeOfDay => DriverDataType.Duration,
|
||||
TwinCATDataType.Date or TwinCATDataType.DateTime => DriverDataType.DateTime,
|
||||
TwinCATDataType.Structure => DriverDataType.String,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
@@ -108,6 +108,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
/// <summary>
|
||||
/// Read the supplied tag references in as few AMS round-trips as possible.
|
||||
/// Tags resolved to the same <c>DeviceHostAddress</c> are bucketed + sent as one
|
||||
/// ADS Sum-read (<see cref="ITwinCATClient.ReadValuesAsync"/>) — N tags in one
|
||||
/// request beats N individual <c>ReadValueAsync</c> calls by ~10× for typical PLC
|
||||
/// loads. Tags with bit-extracted BOOL or whole-array shape stay on the per-tag
|
||||
/// path because the sum-read surface only marshals scalars.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -115,6 +123,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
// Resolve tag definitions + bucket bulk-eligible reads by device. Anything that
|
||||
// doesn't fit the bulk surface (unknown ref, bit BOOL, whole-array) is processed
|
||||
// through the per-tag path inline so we still return a full result array in
|
||||
// request order.
|
||||
var bulkBuckets = new Dictionary<string, List<(int origIndex, string symbol, TwinCATTagDefinition def, int? bitIndex)>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
var reference = fullReferences[i];
|
||||
@@ -123,31 +137,66 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out _))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var bitIndex = parsed?.BitIndex;
|
||||
var isWholeArray = def.ArrayDimensions is { Length: > 0 };
|
||||
var isBitBool = bitIndex is int && def.DataType == TwinCATDataType.Bool;
|
||||
|
||||
if (isWholeArray || isBitBool)
|
||||
{
|
||||
// Per-tag fallback path — preserves bit-extract / whole-array logic in
|
||||
// AdsTwinCATClient.ReadValueAsync.
|
||||
results[i] = await ReadOneAsync(reference, def, symbolName, bitIndex, cancellationToken, now)
|
||||
.ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!bulkBuckets.TryGetValue(def.DeviceHostAddress, out var bucket))
|
||||
{
|
||||
bucket = new List<(int, string, TwinCATTagDefinition, int?)>();
|
||||
bulkBuckets[def.DeviceHostAddress] = bucket;
|
||||
}
|
||||
bucket.Add((i, symbolName, def, bitIndex));
|
||||
}
|
||||
|
||||
// One sum-read per device bucket. Ordering inside a bucket is preserved by the
|
||||
// (origIndex, ...) tuple — the result array entry comes from the parallel index.
|
||||
foreach (var (hostAddress, bucket) in bulkBuckets)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device)) continue;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var (value, status) = await client.ReadValueAsync(
|
||||
symbolName, def.DataType, parsed?.BitIndex, cancellationToken).ConfigureAwait(false);
|
||||
var items = new TwinCATBulkReadItem[bucket.Count];
|
||||
for (var k = 0; k < bucket.Count; k++)
|
||||
items[k] = new TwinCATBulkReadItem(bucket[k].symbol, bucket[k].def.DataType);
|
||||
|
||||
results[i] = new DataValueSnapshot(value, status, now, now);
|
||||
if (status == TwinCATStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
else
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"ADS status {status:X8} reading {reference}");
|
||||
var bulk = await client.ReadValuesAsync(items, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
for (var k = 0; k < bucket.Count; k++)
|
||||
{
|
||||
var (origIndex, _, def, _) = bucket[k];
|
||||
var (value, status) = bulk[k];
|
||||
results[origIndex] = new DataValueSnapshot(value, status, now, now);
|
||||
if (status == TwinCATStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
else
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"ADS status {status:X8} reading {fullReferences[origIndex]}");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
|
||||
foreach (var (origIndex, _, _, _) in bucket)
|
||||
results[origIndex] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -155,14 +204,53 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<DataValueSnapshot> ReadOneAsync(
|
||||
string reference, TwinCATTagDefinition def, string symbolName, int? bitIndex,
|
||||
CancellationToken cancellationToken, DateTime timestamp)
|
||||
{
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
return new DataValueSnapshot(null, TwinCATStatusMapper.BadNodeIdUnknown, null, timestamp);
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var (value, status) = await client.ReadValueAsync(
|
||||
symbolName, def.DataType, bitIndex, def.ArrayDimensions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (status == TwinCATStatusMapper.Good)
|
||||
_health = new DriverHealth(DriverState.Healthy, timestamp, null);
|
||||
else
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
|
||||
$"ADS status {status:X8} reading {reference}");
|
||||
|
||||
return new DataValueSnapshot(value, status, timestamp, timestamp);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
return new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
/// <summary>
|
||||
/// Write the supplied requests, bucketing scalar writes by device + dispatching
|
||||
/// each bucket as one ADS Sum-write. Bit-RMW BOOL writes + whole-array writes use
|
||||
/// the per-tag <see cref="ITwinCATClient.WriteValueAsync"/> path so the per-parent
|
||||
/// RMW lock stays in play.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(writes);
|
||||
var results = new WriteResult[writes.Count];
|
||||
|
||||
// Bucket scalar writes by device. Bit-BOOL + whole-array writes route through the
|
||||
// per-tag fallback below.
|
||||
var bulkBuckets = new Dictionary<string, List<(int origIndex, string symbol, TwinCATTagDefinition def, object? value)>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
@@ -176,38 +264,68 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNotWritable);
|
||||
continue;
|
||||
}
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out _))
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var bitIndex = parsed?.BitIndex;
|
||||
var isWholeArray = def.ArrayDimensions is { Length: > 0 };
|
||||
var isBitBool = bitIndex is int && def.DataType == TwinCATDataType.Bool;
|
||||
|
||||
if (isWholeArray || isBitBool)
|
||||
{
|
||||
results[i] = await WriteOneAsync(def, symbolName, bitIndex, w.Value, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!bulkBuckets.TryGetValue(def.DeviceHostAddress, out var bucket))
|
||||
{
|
||||
bucket = new List<(int, string, TwinCATTagDefinition, object?)>();
|
||||
bulkBuckets[def.DeviceHostAddress] = bucket;
|
||||
}
|
||||
bucket.Add((i, symbolName, def, w.Value));
|
||||
}
|
||||
|
||||
foreach (var (hostAddress, bucket) in bulkBuckets)
|
||||
{
|
||||
if (!_devices.TryGetValue(hostAddress, out var device)) continue;
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
|
||||
var status = await client.WriteValueAsync(
|
||||
symbolName, def.DataType, parsed?.BitIndex, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(status);
|
||||
var items = new TwinCATBulkWriteItem[bucket.Count];
|
||||
for (var k = 0; k < bucket.Count; k++)
|
||||
items[k] = new TwinCATBulkWriteItem(bucket[k].symbol, bucket[k].def.DataType, bucket[k].value);
|
||||
|
||||
var bulk = await client.WriteValuesAsync(items, cancellationToken).ConfigureAwait(false);
|
||||
for (var k = 0; k < bucket.Count; k++)
|
||||
results[bucket[k].origIndex] = new WriteResult(bulk[k]);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
|
||||
foreach (var (origIndex, _, _, _) in bucket)
|
||||
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadOutOfRange);
|
||||
foreach (var (origIndex, _, _, _) in bucket)
|
||||
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
foreach (var (origIndex, _, _, _) in bucket)
|
||||
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadNotSupported);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
|
||||
foreach (var (origIndex, _, _, _) in bucket)
|
||||
results[origIndex] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
@@ -215,6 +333,40 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<WriteResult> WriteOneAsync(
|
||||
TwinCATTagDefinition def, string symbolName, int? bitIndex, object? value, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
|
||||
return new WriteResult(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
|
||||
try
|
||||
{
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var status = await client.WriteValueAsync(
|
||||
symbolName, def.DataType, bitIndex, def.ArrayDimensions, value, cancellationToken).ConfigureAwait(false);
|
||||
return new WriteResult(status);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
|
||||
return new WriteResult(TwinCATStatusMapper.BadNotSupported);
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException)
|
||||
{
|
||||
return new WriteResult(TwinCATStatusMapper.BadTypeMismatch);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
return new WriteResult(TwinCATStatusMapper.BadOutOfRange);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
return new WriteResult(TwinCATStatusMapper.BadCommunicationError);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
@@ -231,11 +383,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
string.Equals(t.DeviceHostAddress, device.HostAddress, StringComparison.OrdinalIgnoreCase));
|
||||
foreach (var tag in tagsForDevice)
|
||||
{
|
||||
var (isArray, arrayDim) = ResolveArrayShape(tag.ArrayDimensions);
|
||||
deviceFolder.Variable(tag.Name, tag.Name, new DriverAttributeInfo(
|
||||
FullName: tag.Name,
|
||||
DriverDataType: tag.DataType.ToDriverDataType(),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
IsArray: isArray,
|
||||
ArrayDim: arrayDim,
|
||||
SecurityClass: tag.Writable
|
||||
? SecurityClassification.Operate
|
||||
: SecurityClassification.ViewOnly,
|
||||
@@ -310,6 +463,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
{
|
||||
if (!_tagsByName.TryGetValue(reference, out var def)) continue;
|
||||
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
|
||||
// Whole-array tags don't fit the per-element AdsNotificationEx callback shape —
|
||||
// skip the native path so the OPC UA layer falls through to a polled snapshot.
|
||||
if (def.ArrayDimensions is { Length: > 0 }) continue;
|
||||
|
||||
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
|
||||
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
|
||||
@@ -428,6 +584,25 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
|
||||
return device.Client;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Project a TwinCAT <see cref="TwinCATTagDefinition.ArrayDimensions"/> shape onto the
|
||||
/// core <see cref="DriverAttributeInfo"/> 1-D surface. Multi-dim arrays flatten to the
|
||||
/// product element count — the OPC UA address-space layer surfaces the rank via its own
|
||||
/// <c>ArrayDimensions</c> metadata at variable build time.
|
||||
/// </summary>
|
||||
internal static (bool isArray, uint? arrayDim) ResolveArrayShape(int[]? dimensions)
|
||||
{
|
||||
if (dimensions is null || dimensions.Length == 0) return (false, null);
|
||||
long product = 1;
|
||||
foreach (var d in dimensions)
|
||||
{
|
||||
if (d <= 0) return (false, null); // invalid shape; surface as scalar to fail safe
|
||||
product *= d;
|
||||
if (product > uint.MaxValue) return (true, uint.MaxValue);
|
||||
}
|
||||
return (true, (uint)product);
|
||||
}
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync() => await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
|
||||
|
||||
@@ -43,8 +43,12 @@ public sealed record TwinCATDeviceOptions(
|
||||
string? DeviceName = null);
|
||||
|
||||
/// <summary>
|
||||
/// One TwinCAT-backed OPC UA variable. <paramref name="SymbolPath"/> is the full TwinCAT
|
||||
/// symbolic name (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>).
|
||||
/// One TwinCAT-backed OPC UA variable. <c>SymbolPath</c> is the full TwinCAT symbolic name
|
||||
/// (e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>, <c>Motor1.Status.Running</c>). When
|
||||
/// <c>ArrayDimensions</c> is non-null + non-empty the symbol is treated as a whole-array
|
||||
/// read of <c>product(dims)</c> elements rather than a single scalar — PR-1.4 ships read-
|
||||
/// only whole-array support; multi-dim shapes flatten to the product on the wire and the
|
||||
/// OPC UA layer reflects the rank via its own <c>ArrayDimensions</c> metadata.
|
||||
/// </summary>
|
||||
public sealed record TwinCATTagDefinition(
|
||||
string Name,
|
||||
@@ -52,7 +56,8 @@ public sealed record TwinCATTagDefinition(
|
||||
string SymbolPath,
|
||||
TwinCATDataType DataType,
|
||||
bool Writable = true,
|
||||
bool WriteIdempotent = false);
|
||||
bool WriteIdempotent = false,
|
||||
int[]? ArrayDimensions = null);
|
||||
|
||||
public sealed class TwinCATProbeOptions
|
||||
{
|
||||
|
||||
@@ -178,6 +178,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex),
|
||||
BrowseName = new QualifiedName(browseName, NamespaceIndex),
|
||||
DisplayName = new LocalizedText(displayName),
|
||||
// Per Task #231 — surface the driver-supplied tag description as the OPC UA
|
||||
// Description attribute on the Variable node. Drivers that don't carry
|
||||
// descriptions pass null, leaving Description unset (the stack defaults to
|
||||
// an empty LocalizedText, matching prior behaviour).
|
||||
Description = string.IsNullOrEmpty(attributeInfo.Description)
|
||||
? null
|
||||
: new LocalizedText(attributeInfo.Description),
|
||||
DataType = MapDataType(attributeInfo.DriverDataType),
|
||||
ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
|
||||
// Historized attributes get the HistoryRead access bit so the stack dispatches
|
||||
@@ -310,6 +317,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
DriverDataType.Float64 => DataTypeIds.Double,
|
||||
DriverDataType.String => DataTypeIds.String,
|
||||
DriverDataType.DateTime => DataTypeIds.DateTime,
|
||||
DriverDataType.Duration => DataTypeIds.Duration,
|
||||
_ => DataTypeIds.BaseDataType,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipArrayReadPlannerTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
|
||||
private static AbCipTagCreateParams BaseParams(string tagName) => new(
|
||||
Gateway: "10.0.0.5",
|
||||
Port: 44818,
|
||||
CipPath: "1,0",
|
||||
LibplctagPlcAttribute: "controllogix",
|
||||
TagName: tagName,
|
||||
Timeout: TimeSpan.FromSeconds(5));
|
||||
|
||||
[Fact]
|
||||
public void TryBuild_emits_single_tag_create_with_element_count()
|
||||
{
|
||||
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..15]", AbCipDataType.DInt);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..15]"));
|
||||
|
||||
plan.ShouldNotBeNull();
|
||||
plan.ElementType.ShouldBe(AbCipDataType.DInt);
|
||||
plan.Stride.ShouldBe(4);
|
||||
plan.Slice.Count.ShouldBe(16);
|
||||
plan.CreateParams.ElementCount.ShouldBe(16);
|
||||
// Anchored at the slice start; libplctag reads N consecutive elements from there.
|
||||
plan.CreateParams.TagName.ShouldBe("Data[0]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryBuild_returns_null_when_path_has_no_slice()
|
||||
{
|
||||
var def = new AbCipTagDefinition("Plain", Device, "Data[3]", AbCipDataType.DInt);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
|
||||
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[3]")).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbCipDataType.Bool)]
|
||||
[InlineData(AbCipDataType.String)]
|
||||
[InlineData(AbCipDataType.Structure)]
|
||||
public void TryBuild_returns_null_for_unsupported_element_types(AbCipDataType type)
|
||||
{
|
||||
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
|
||||
AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]")).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbCipDataType.SInt, 1)]
|
||||
[InlineData(AbCipDataType.Int, 2)]
|
||||
[InlineData(AbCipDataType.DInt, 4)]
|
||||
[InlineData(AbCipDataType.Real, 4)]
|
||||
[InlineData(AbCipDataType.LInt, 8)]
|
||||
[InlineData(AbCipDataType.LReal, 8)]
|
||||
public void TryBuild_uses_natural_stride_per_element_type(AbCipDataType type, int expectedStride)
|
||||
{
|
||||
var def = new AbCipTagDefinition("Slice", Device, "Data[0..3]", type);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
|
||||
plan.Stride.ShouldBe(expectedStride);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_walks_buffer_at_element_stride()
|
||||
{
|
||||
var def = new AbCipTagDefinition("DataSlice", Device, "Data[0..3]", AbCipDataType.DInt);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Data[0..3]"))!;
|
||||
|
||||
var fake = new FakeAbCipTag(plan.CreateParams);
|
||||
// Stride == 4 for DInt, so offsets 0/4/8/12 hold the four element values.
|
||||
fake.ValuesByOffset[0] = 100;
|
||||
fake.ValuesByOffset[4] = 200;
|
||||
fake.ValuesByOffset[8] = 300;
|
||||
fake.ValuesByOffset[12] = 400;
|
||||
|
||||
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
|
||||
|
||||
decoded.Length.ShouldBe(4);
|
||||
decoded.ShouldBe(new object?[] { 100, 200, 300, 400 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decode_preserves_slice_count_for_real_arrays()
|
||||
{
|
||||
var def = new AbCipTagDefinition("FloatSlice", Device, "Floats[2..5]", AbCipDataType.Real);
|
||||
var parsed = AbCipTagPath.TryParse(def.TagPath)!;
|
||||
var plan = AbCipArrayReadPlanner.TryBuild(def, parsed, BaseParams("Floats[2]"))!;
|
||||
|
||||
var fake = new FakeAbCipTag(plan.CreateParams);
|
||||
fake.ValuesByOffset[0] = 1.5f;
|
||||
fake.ValuesByOffset[4] = 2.5f;
|
||||
fake.ValuesByOffset[8] = 3.5f;
|
||||
fake.ValuesByOffset[12] = 4.5f;
|
||||
|
||||
var decoded = AbCipArrayReadPlanner.Decode(plan, fake);
|
||||
|
||||
decoded.ShouldBe(new object?[] { 1.5f, 2.5f, 3.5f, 4.5f });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task #231 — verifies that tag/member descriptions parsed from L5K and L5X exports thread
|
||||
/// through <see cref="AbCipTagDefinition.Description"/> /
|
||||
/// <see cref="AbCipStructureMember.Description"/> + land on
|
||||
/// <see cref="DriverAttributeInfo.Description"/> on the produced address-space variables, so
|
||||
/// downstream OPC UA Variable nodes carry the source-project comment as their Description
|
||||
/// attribute.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipDescriptionThreadingTests
|
||||
{
|
||||
private const string DeviceHost = "ab://10.0.0.5/1,0";
|
||||
|
||||
[Fact]
|
||||
public void L5kParser_captures_member_description_from_attribute_block()
|
||||
{
|
||||
const string body = """
|
||||
DATATYPE MyUdt
|
||||
MEMBER Speed : DINT (Description := "Belt speed in RPM");
|
||||
END_DATATYPE
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var member = doc.DataTypes.Single().Members.Single();
|
||||
member.Name.ShouldBe("Speed");
|
||||
member.Description.ShouldBe("Belt speed in RPM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void L5xParser_captures_member_description_child_node()
|
||||
{
|
||||
const string xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller>
|
||||
<DataTypes>
|
||||
<DataType Name="MyUdt">
|
||||
<Members>
|
||||
<Member Name="Speed" DataType="DINT" Dimension="0" ExternalAccess="Read/Write">
|
||||
<Description><![CDATA[Belt speed in RPM]]></Description>
|
||||
</Member>
|
||||
</Members>
|
||||
</DataType>
|
||||
</DataTypes>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
var doc = L5xParser.Parse(new StringL5kSource(xml));
|
||||
|
||||
doc.DataTypes.Single().Members.Single().Description.ShouldBe("Belt speed in RPM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void L5kIngest_threads_tag_and_member_descriptions_into_AbCipTagDefinition()
|
||||
{
|
||||
const string body = """
|
||||
DATATYPE MotorBlock
|
||||
MEMBER Speed : DINT (Description := "Setpoint RPM");
|
||||
MEMBER Status : DINT;
|
||||
END_DATATYPE
|
||||
TAG
|
||||
Motor1 : MotorBlock (Description := "Conveyor motor 1") := [];
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
var tag = result.Tags.Single();
|
||||
tag.Description.ShouldBe("Conveyor motor 1");
|
||||
tag.Members.ShouldNotBeNull();
|
||||
var members = tag.Members!.ToDictionary(m => m.Name);
|
||||
members["Speed"].Description.ShouldBe("Setpoint RPM");
|
||||
members["Status"].Description.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_sets_Description_on_DriverAttributeInfo_for_atomic_tag()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(DeviceHost)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition(
|
||||
Name: "Speed",
|
||||
DeviceHostAddress: DeviceHost,
|
||||
TagPath: "Motor1.Speed",
|
||||
DataType: AbCipDataType.DInt,
|
||||
Description: "Belt speed in RPM"),
|
||||
new AbCipTagDefinition(
|
||||
Name: "NoDescription",
|
||||
DeviceHostAddress: DeviceHost,
|
||||
TagPath: "X",
|
||||
DataType: AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Single(v => v.BrowseName == "Speed").Info.Description
|
||||
.ShouldBe("Belt speed in RPM");
|
||||
// Tags without descriptions leave Info.Description null (back-compat path).
|
||||
builder.Variables.Single(v => v.BrowseName == "NoDescription").Info.Description
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_sets_Description_on_DriverAttributeInfo_for_UDT_members()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(DeviceHost)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition(
|
||||
Name: "Motor1",
|
||||
DeviceHostAddress: DeviceHost,
|
||||
TagPath: "Motor1",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember(
|
||||
Name: "Speed",
|
||||
DataType: AbCipDataType.DInt,
|
||||
Description: "Setpoint RPM"),
|
||||
new AbCipStructureMember(
|
||||
Name: "Status",
|
||||
DataType: AbCipDataType.DInt),
|
||||
]),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Single(v => v.BrowseName == "Speed").Info.Description
|
||||
.ShouldBe("Setpoint RPM");
|
||||
builder.Variables.Single(v => v.BrowseName == "Status").Info.Description
|
||||
.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,55 @@ public sealed class AbCipDriverReadTests
|
||||
p.TagName.ShouldBe("Program:P.Counter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Slice_tag_reads_one_array_and_decodes_n_elements()
|
||||
{
|
||||
// PR abcip-1.3 — `Data[0..3]` slice routes through AbCipArrayReadPlanner: one libplctag
|
||||
// tag-create at TagName="Data[0]" with ElementCount=4, single PLC read, contiguous
|
||||
// buffer decoded at element stride into one snapshot whose Value is an object?[].
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("DataSlice", "ab://10.0.0.5/1,0", "Data[0..3]", AbCipDataType.DInt));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p =>
|
||||
{
|
||||
var t = new FakeAbCipTag(p);
|
||||
t.ValuesByOffset[0] = 10;
|
||||
t.ValuesByOffset[4] = 20;
|
||||
t.ValuesByOffset[8] = 30;
|
||||
t.ValuesByOffset[12] = 40;
|
||||
return t;
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["DataSlice"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
var values = snapshots.Single().Value.ShouldBeOfType<object?[]>();
|
||||
values.ShouldBe(new object?[] { 10, 20, 30, 40 });
|
||||
|
||||
// Exactly ONE libplctag tag was created — anchored at the slice start with
|
||||
// ElementCount=4. Without the planner this would have been four scalar reads.
|
||||
factory.Tags.Count.ShouldBe(1);
|
||||
factory.Tags.ShouldContainKey("Data[0]");
|
||||
factory.Tags["Data[0]"].CreationParams.ElementCount.ShouldBe(4);
|
||||
factory.Tags["Data[0]"].ReadCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Slice_tag_with_unsupported_element_type_returns_BadNotSupported()
|
||||
{
|
||||
// BOOL slices can't be laid out from the declaration alone (Logix packs BOOLs into a
|
||||
// hidden host byte). The planner refuses; the driver surfaces BadNotSupported instead
|
||||
// of attempting a best-effort decode.
|
||||
var (drv, _) = NewDriver(
|
||||
new AbCipTagDefinition("BoolSlice", "ab://10.0.0.5/1,0", "Flags[0..7]", AbCipDataType.Bool));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["BoolSlice"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported);
|
||||
snapshots.Single().Value.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates_from_read()
|
||||
{
|
||||
@@ -211,4 +260,79 @@ public sealed class AbCipDriverReadTests
|
||||
snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError);
|
||||
factory.Tags["Nope"].Disposed.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// PR abcip-1.2 — STRINGnn variant decoding. Threading <see cref="AbCipTagDefinition.StringLength"/>
|
||||
// through libplctag's StringMaxCapacity attribute lets STRING_20 / STRING_40 / STRING_80 UDTs
|
||||
// decode against the right DATA-array size; null preserves the default 82-byte STRING.
|
||||
|
||||
[Fact]
|
||||
public async Task StringLength_threads_into_TagCreateParams_StringMaxCapacity()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Banner", "ab://10.0.0.5/1,0", "Banner", AbCipDataType.String,
|
||||
StringLength: 40));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = "hello" };
|
||||
|
||||
await drv.ReadAsync(["Banner"], CancellationToken.None);
|
||||
|
||||
factory.Tags["Banner"].CreationParams.StringMaxCapacity.ShouldBe(40);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StringLength_null_leaves_StringMaxCapacity_null_for_back_compat()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("LegacyStr", "ab://10.0.0.5/1,0", "LegacyStr", AbCipDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = "world" };
|
||||
|
||||
await drv.ReadAsync(["LegacyStr"], CancellationToken.None);
|
||||
|
||||
factory.Tags["LegacyStr"].CreationParams.StringMaxCapacity.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StringLength_ignored_for_non_String_data_types()
|
||||
{
|
||||
// StringLength on a DINT-typed tag must not flow into StringMaxCapacity — libplctag would
|
||||
// otherwise re-shape the buffer and corrupt the read. EnsureTagRuntimeAsync gates on the
|
||||
// declared DataType.
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt,
|
||||
StringLength: 80));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = 7 };
|
||||
|
||||
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
|
||||
factory.Tags["Speed"].CreationParams.StringMaxCapacity.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_member_StringLength_threads_through_to_member_runtime()
|
||||
{
|
||||
// STRINGnn members of a UDT — declaration-driven fan-out copies StringLength from
|
||||
// AbCipStructureMember onto the synthesised member AbCipTagDefinition; the per-member
|
||||
// runtime then receives the right StringMaxCapacity.
|
||||
var udt = new AbCipTagDefinition(
|
||||
Name: "Recipe",
|
||||
DeviceHostAddress: "ab://10.0.0.5/1,0",
|
||||
TagPath: "Recipe",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members: [
|
||||
new AbCipStructureMember("Name", AbCipDataType.String, StringLength: 20),
|
||||
new AbCipStructureMember("Description", AbCipDataType.String, StringLength: 80),
|
||||
new AbCipStructureMember("Code", AbCipDataType.DInt),
|
||||
]);
|
||||
var (drv, factory) = NewDriver(udt);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbCipTag(p) { Value = "x" };
|
||||
|
||||
await drv.ReadAsync(["Recipe.Name", "Recipe.Description", "Recipe.Code"], CancellationToken.None);
|
||||
|
||||
factory.Tags["Recipe.Name"].CreationParams.StringMaxCapacity.ShouldBe(20);
|
||||
factory.Tags["Recipe.Description"].CreationParams.StringMaxCapacity.ShouldBe(80);
|
||||
factory.Tags["Recipe.Code"].CreationParams.StringMaxCapacity.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,8 +124,11 @@ public sealed class AbCipDriverTests
|
||||
{
|
||||
AbCipDataType.Bool.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
AbCipDataType.DInt.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
AbCipDataType.LInt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
|
||||
AbCipDataType.ULInt.ToDriverDataType().ShouldBe(DriverDataType.UInt64);
|
||||
AbCipDataType.Real.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
AbCipDataType.LReal.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||
AbCipDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
AbCipDataType.Dt.ToDriverDataType().ShouldBe(DriverDataType.Int64);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-1.4 — multi-tag write packing. Validates that <see cref="AbCipDriver.WriteAsync"/>
|
||||
/// groups writes by device, dispatches packable writes for request-packing-capable
|
||||
/// families concurrently, falls back to sequential writes on Micro800, keeps BOOL-RMW
|
||||
/// writes on the per-parent semaphore path, and fans per-tag StatusCodes out to the
|
||||
/// correct positions on partial failures.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipMultiWritePackingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Writes_get_grouped_by_device()
|
||||
{
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A1", "ab://10.0.0.5/1,0", "A1", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("A2", "ab://10.0.0.5/1,0", "A2", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B1", "ab://10.0.0.6/1,0", "B1", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A1", 1),
|
||||
new WriteRequest("B1", 100),
|
||||
new WriteRequest("A2", 2),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
// Per-device handles materialised — A1/A2 share device A, B1 lives on device B.
|
||||
factory.Tags["A1"].CreationParams.Gateway.ShouldBe("10.0.0.5");
|
||||
factory.Tags["A2"].CreationParams.Gateway.ShouldBe("10.0.0.5");
|
||||
factory.Tags["B1"].CreationParams.Gateway.ShouldBe("10.0.0.6");
|
||||
factory.Tags["A1"].WriteCount.ShouldBe(1);
|
||||
factory.Tags["A2"].WriteCount.ShouldBe(1);
|
||||
factory.Tags["B1"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ControlLogix_packs_concurrently_within_a_device()
|
||||
{
|
||||
// ControlLogix has SupportsRequestPacking=true → a multi-write batch is dispatched in
|
||||
// parallel. The fake's WriteAsync gates on a TaskCompletionSource so we can prove that
|
||||
// both writes are in flight at the same time before either completes.
|
||||
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var inFlight = 0;
|
||||
var maxInFlight = 0;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new GatedWriteFake(p, gate, () =>
|
||||
{
|
||||
var current = Interlocked.Increment(ref inFlight);
|
||||
var observed = maxInFlight;
|
||||
while (current > observed
|
||||
&& Interlocked.CompareExchange(ref maxInFlight, current, observed) != observed)
|
||||
observed = maxInFlight;
|
||||
}, () => Interlocked.Decrement(ref inFlight)),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", AbCipPlcFamily.ControlLogix)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.5/1,0", "C", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var writeTask = drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("C", 3),
|
||||
], CancellationToken.None);
|
||||
|
||||
// Wait until all three writes have entered WriteAsync simultaneously, then release.
|
||||
await WaitForAsync(() => Volatile.Read(ref inFlight) >= 3, TimeSpan.FromSeconds(2));
|
||||
gate.SetResult(0);
|
||||
|
||||
var results = await writeTask;
|
||||
results.Count.ShouldBe(3);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[2].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
maxInFlight.ShouldBeGreaterThanOrEqualTo(2,
|
||||
"ControlLogix supports request packing — packable writes should run concurrently within the device.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Micro800_falls_back_to_sequential_writes()
|
||||
{
|
||||
// Micro800 has SupportsRequestPacking=false → writes go one-at-a-time; the gated fake
|
||||
// never sees more than one in-flight at a time.
|
||||
var gate = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
gate.SetResult(0); // No need to gate — we just observe concurrency.
|
||||
var inFlight = 0;
|
||||
var maxInFlight = 0;
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => new GatedWriteFake(p, gate, () =>
|
||||
{
|
||||
var current = Interlocked.Increment(ref inFlight);
|
||||
var observed = maxInFlight;
|
||||
while (current > observed
|
||||
&& Interlocked.CompareExchange(ref maxInFlight, current, observed) != observed)
|
||||
observed = maxInFlight;
|
||||
}, () => Interlocked.Decrement(ref inFlight)),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/", AbCipPlcFamily.Micro800)],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/", "B", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.5/", "C", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("C", 3),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(3);
|
||||
results.ShouldAllBe(r => r.StatusCode == AbCipStatusMapper.Good);
|
||||
maxInFlight.ShouldBe(1,
|
||||
"Micro800 disables request packing — writes must execute sequentially.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_in_dint_writes_still_route_through_RMW_path()
|
||||
{
|
||||
// BOOL-with-bitIndex must hit the per-parent RMW semaphore — it must NOT go through
|
||||
// the packable per-tag runtime path. We prove this by checking that:
|
||||
// (a) the per-tag "bit-selector" runtime is never created (it would throw via
|
||||
// LibplctagTagRuntime's NotSupportedException had the bypass happened);
|
||||
// (b) the parent-DINT runtime got both a Read and a Write.
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Flag3", "ab://10.0.0.5/1,0", "Flags.3", AbCipDataType.Bool),
|
||||
new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("Flag3", true),
|
||||
new WriteRequest("Speed", 99),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(2);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
|
||||
// Parent runtime created lazily for Flags (no .3 suffix) — drove the RMW.
|
||||
factory.Tags.ShouldContainKey("Flags");
|
||||
factory.Tags["Flags"].ReadCount.ShouldBe(1);
|
||||
factory.Tags["Flags"].WriteCount.ShouldBe(1);
|
||||
// Speed went through the packable path.
|
||||
factory.Tags["Speed"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Per_tag_status_code_fan_out_works_on_partial_failure()
|
||||
{
|
||||
// Mix Good + BadTimeout + BadNotWritable + BadNodeIdUnknown across two devices to
|
||||
// exercise the original-index preservation through the per-device plan + concurrent
|
||||
// dispatch.
|
||||
var factory = new FakeAbCipTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "B"
|
||||
? new FakeAbCipTag(p) { Status = -5 /* timeout */ }
|
||||
: new FakeAbCipTag(p),
|
||||
};
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new AbCipDeviceOptions("ab://10.0.0.5/1,0"),
|
||||
new AbCipDeviceOptions("ab://10.0.0.6/1,0"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("A", "ab://10.0.0.5/1,0", "A", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("B", "ab://10.0.0.5/1,0", "B", AbCipDataType.DInt),
|
||||
new AbCipTagDefinition("RO", "ab://10.0.0.5/1,0", "RO", AbCipDataType.DInt, Writable: false),
|
||||
new AbCipTagDefinition("C", "ab://10.0.0.6/1,0", "C", AbCipDataType.DInt),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", 1),
|
||||
new WriteRequest("B", 2),
|
||||
new WriteRequest("RO", 3),
|
||||
new WriteRequest("UnknownTag", 4),
|
||||
new WriteRequest("C", 5),
|
||||
], CancellationToken.None);
|
||||
|
||||
results.Count.ShouldBe(5);
|
||||
results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout);
|
||||
results[2].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable);
|
||||
results[3].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown);
|
||||
results[4].StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> predicate, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!predicate())
|
||||
{
|
||||
if (DateTime.UtcNow >= deadline)
|
||||
throw new TimeoutException("predicate did not become true within timeout");
|
||||
await Task.Delay(10).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test fake whose <see cref="WriteAsync"/> blocks on a shared
|
||||
/// <see cref="TaskCompletionSource"/> so the test can observe how many writes are
|
||||
/// simultaneously in flight inside the driver.
|
||||
/// </summary>
|
||||
private sealed class GatedWriteFake : FakeAbCipTag
|
||||
{
|
||||
private readonly TaskCompletionSource<int> _gate;
|
||||
private readonly Action _onEnter;
|
||||
private readonly Action _onExit;
|
||||
|
||||
public GatedWriteFake(AbCipTagCreateParams p, TaskCompletionSource<int> gate,
|
||||
Action onEnter, Action onExit) : base(p)
|
||||
{
|
||||
_gate = gate;
|
||||
_onEnter = onEnter;
|
||||
_onExit = onExit;
|
||||
}
|
||||
|
||||
public override async Task WriteAsync(CancellationToken ct)
|
||||
{
|
||||
_onEnter();
|
||||
try
|
||||
{
|
||||
await _gate.Task.ConfigureAwait(false);
|
||||
await base.WriteAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_onExit();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
141
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRebrowseTests.cs
Normal file
141
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipRebrowseTests.cs
Normal file
@@ -0,0 +1,141 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #233 — RebrowseAsync forces a re-walk of the controller symbol table without
|
||||
/// restarting the driver. Tests cover the call-counting contract (each invocation issues
|
||||
/// a fresh enumeration pass), the IDriverControl interface implementation, and that the
|
||||
/// UDT template cache is dropped so stale shapes don't survive a program-download.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipRebrowseTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RebrowseAsync_runs_enumerator_once_per_call()
|
||||
{
|
||||
var factory = new CountingEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false));
|
||||
|
||||
await using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None);
|
||||
factory.CreateCount.ShouldBe(1);
|
||||
factory.EnumerationCount.ShouldBe(1);
|
||||
|
||||
await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None);
|
||||
factory.CreateCount.ShouldBe(2);
|
||||
factory.EnumerationCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RebrowseAsync_emits_discovered_tags_through_supplied_builder()
|
||||
{
|
||||
var factory = new CountingEnumeratorFactory(
|
||||
new AbCipDiscoveredTag("NewTag", null, AbCipDataType.DInt, ReadOnly: false));
|
||||
|
||||
await using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
EnableControllerBrowse = true,
|
||||
}, "drv-1", enumeratorFactory: factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.RebrowseAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Variables.Select(v => v.Info.FullName).ShouldContain("NewTag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RebrowseAsync_clears_template_cache()
|
||||
{
|
||||
await using var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.TemplateCache.Put("ab://10.0.0.5/1,0", 42, new AbCipUdtShape("T", 4, []));
|
||||
drv.TemplateCache.Count.ShouldBe(1);
|
||||
|
||||
await drv.RebrowseAsync(new RecordingBuilder(), CancellationToken.None);
|
||||
|
||||
drv.TemplateCache.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AbCipDriver_implements_IDriverControl()
|
||||
{
|
||||
await using var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
|
||||
drv.ShouldBeAssignableTo<IDriverControl>();
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink
|
||||
{
|
||||
public void OnTransition(AlarmEventArgs args) { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks both <see cref="Create"/> calls (one per discovery / rebrowse pass) and
|
||||
/// <see cref="EnumerationCount"/> (incremented when the resulting enumerator is
|
||||
/// actually iterated). Two consecutive RebrowseAsync calls must bump both counters.
|
||||
/// </summary>
|
||||
private sealed class CountingEnumeratorFactory : IAbCipTagEnumeratorFactory
|
||||
{
|
||||
private readonly AbCipDiscoveredTag[] _tags;
|
||||
public int CreateCount { get; private set; }
|
||||
public int EnumerationCount { get; private set; }
|
||||
|
||||
public CountingEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
|
||||
|
||||
public IAbCipTagEnumerator Create()
|
||||
{
|
||||
CreateCount++;
|
||||
return new CountingEnumerator(this);
|
||||
}
|
||||
|
||||
private sealed class CountingEnumerator(CountingEnumeratorFactory outer) : IAbCipTagEnumerator
|
||||
{
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
outer.EnumerationCount++;
|
||||
await Task.CompletedTask;
|
||||
foreach (var t in outer._tags) yield return t;
|
||||
}
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,61 @@ public sealed class AbCipTagPathTests
|
||||
AbCipTagPath.TryParse("_private_tag")!.Segments.Single().Name.ShouldBe("_private_tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slice_basic_inclusive_range()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Data[0..15]");
|
||||
p.ShouldNotBeNull();
|
||||
p.Slice.ShouldNotBeNull();
|
||||
p.Slice!.Start.ShouldBe(0);
|
||||
p.Slice.End.ShouldBe(15);
|
||||
p.Slice.Count.ShouldBe(16);
|
||||
p.BitIndex.ShouldBeNull();
|
||||
p.Segments.Single().Name.ShouldBe("Data");
|
||||
p.Segments.Single().Subscripts.ShouldBeEmpty();
|
||||
p.ToLibplctagName().ShouldBe("Data[0..15]");
|
||||
// Slice array name omits the `..End` so libplctag sees an anchored read at the start
|
||||
// index; pair with ElementCount to cover the whole range.
|
||||
p.ToLibplctagSliceArrayName().ShouldBe("Data[0]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slice_with_program_scope_and_member_chain()
|
||||
{
|
||||
var p = AbCipTagPath.TryParse("Program:MainProgram.Motors.Data[3..7]");
|
||||
p.ShouldNotBeNull();
|
||||
p.ProgramScope.ShouldBe("MainProgram");
|
||||
p.Segments.Select(s => s.Name).ShouldBe(["Motors", "Data"]);
|
||||
p.Slice!.Start.ShouldBe(3);
|
||||
p.Slice.End.ShouldBe(7);
|
||||
p.ToLibplctagName().ShouldBe("Program:MainProgram.Motors.Data[3..7]");
|
||||
p.ToLibplctagSliceArrayName().ShouldBe("Program:MainProgram.Motors.Data[3]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slice_zero_length_single_element_allowed()
|
||||
{
|
||||
// [5..5] is a one-element slice — degenerate but legal (a single read of one element).
|
||||
var p = AbCipTagPath.TryParse("Data[5..5]");
|
||||
p.ShouldNotBeNull();
|
||||
p.Slice!.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Data[5..3]")] // M < N
|
||||
[InlineData("Data[-1..5]")] // negative start
|
||||
[InlineData("Data[0..15].Member")] // slice + sub-element
|
||||
[InlineData("Data[0..15].3")] // slice + bit index
|
||||
[InlineData("Data[0..15,1]")] // slice cannot be multi-dim
|
||||
[InlineData("Data[0..15,2..3]")] // multi-dim slice not supported
|
||||
[InlineData("Data[..5]")] // missing start
|
||||
[InlineData("Data[5..]")] // missing end
|
||||
[InlineData("Data[a..5]")] // non-numeric start
|
||||
public void Invalid_slice_shapes_return_null(string input)
|
||||
{
|
||||
AbCipTagPath.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLibplctagName_recomposes_round_trip()
|
||||
{
|
||||
|
||||
@@ -167,6 +167,83 @@ public sealed class AbCipUdtMemberTests
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "EmptyUdt");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AOI_typed_tag_groups_members_under_directional_subfolders()
|
||||
{
|
||||
// PR abcip-2.6 — when any member carries a non-Local AoiQualifier, the tag is treated
|
||||
// as an AOI instance: Input / Output / InOut members get grouped under sub-folders so
|
||||
// the browse tree mirrors Studio 5000's AOI parameter tabs. Plain UDT tags (every member
|
||||
// Local) keep the pre-2.6 flat layout.
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition(
|
||||
Name: "Valve_001",
|
||||
DeviceHostAddress: "ab://10.0.0.5/1,0",
|
||||
TagPath: "Valve_001",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Cmd", AbCipDataType.Bool, AoiQualifier: AoiQualifier.Input),
|
||||
new AbCipStructureMember("Status", AbCipDataType.DInt, Writable: false, AoiQualifier: AoiQualifier.Output),
|
||||
new AbCipStructureMember("Buffer", AbCipDataType.DInt, AoiQualifier: AoiQualifier.InOut),
|
||||
new AbCipStructureMember("LocalVar", AbCipDataType.DInt, AoiQualifier: AoiQualifier.Local),
|
||||
]),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
// Sub-folders for each directional bucket land in the recorder; the AOI parent folder
|
||||
// and the Local member's lack of a sub-folder confirm only directional members get
|
||||
// bucketed. Folder names are intentionally simple (Inputs / Outputs / InOut) — clients
|
||||
// that browse "Valve_001/Inputs/Cmd" see exactly that path.
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldContain("Valve_001");
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldContain("Inputs");
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldContain("Outputs");
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldContain("InOut");
|
||||
|
||||
// Variables emitted under the right full names — full reference still {Tag}.{Member}
|
||||
// so the read/write paths stay unchanged from the flat-UDT case.
|
||||
var variables = builder.Variables.Select(v => (v.BrowseName, v.Info.FullName)).ToList();
|
||||
variables.ShouldContain(("Cmd", "Valve_001.Cmd"));
|
||||
variables.ShouldContain(("Status", "Valve_001.Status"));
|
||||
variables.ShouldContain(("Buffer", "Valve_001.Buffer"));
|
||||
variables.ShouldContain(("LocalVar", "Valve_001.LocalVar"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Plain_UDT_keeps_flat_layout_when_every_member_is_Local()
|
||||
{
|
||||
// Plain UDTs (no Usage attributes anywhere) stay on the pre-2.6 flat layout — no
|
||||
// Inputs/Outputs/InOut sub-folders should appear since there are no directional members.
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
|
||||
Tags =
|
||||
[
|
||||
new AbCipTagDefinition("Tank1", "ab://10.0.0.5/1,0", "Tank1", AbCipDataType.Structure,
|
||||
Members:
|
||||
[
|
||||
new AbCipStructureMember("Level", AbCipDataType.Real),
|
||||
new AbCipStructureMember("Pressure", AbCipDataType.Real),
|
||||
]),
|
||||
],
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("Inputs");
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("Outputs");
|
||||
builder.Folders.Select(f => f.BrowseName).ShouldNotContain("InOut");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UDT_members_mixed_with_flat_tags_coexist()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CsvTagImporterTests
|
||||
{
|
||||
private const string DeviceHost = "ab://10.10.10.1/0,1";
|
||||
|
||||
[Fact]
|
||||
public void Imports_Kepware_format_controller_tag_with_RW_access()
|
||||
{
|
||||
const string csv = """
|
||||
Tag Name,Address,Data Type,Respect Data Type,Client Access,Scan Rate,Description,Scaling
|
||||
Motor1_Speed,Motor1_Speed,DINT,1,Read/Write,100,Drive speed setpoint,None
|
||||
""";
|
||||
|
||||
var importer = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost };
|
||||
var result = importer.Import(csv);
|
||||
|
||||
result.Tags.Count.ShouldBe(1);
|
||||
var t = result.Tags[0];
|
||||
t.Name.ShouldBe("Motor1_Speed");
|
||||
t.TagPath.ShouldBe("Motor1_Speed");
|
||||
t.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
t.Writable.ShouldBeTrue();
|
||||
t.Description.ShouldBe("Drive speed setpoint");
|
||||
t.DeviceHostAddress.ShouldBe(DeviceHost);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Read_Only_access_yields_non_writable_tag()
|
||||
{
|
||||
const string csv = """
|
||||
Tag Name,Address,Data Type,Client Access,Description
|
||||
Sensor,Sensor,REAL,Read Only,Pressure sensor
|
||||
""";
|
||||
|
||||
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||
|
||||
result.Tags.Single().Writable.ShouldBeFalse();
|
||||
result.Tags.Single().DataType.ShouldBe(AbCipDataType.Real);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blank_rows_and_section_markers_are_skipped()
|
||||
{
|
||||
const string csv = """
|
||||
; Kepware Server Tag Export
|
||||
|
||||
Tag Name,Address,Data Type,Client Access
|
||||
|
||||
; group: Motors
|
||||
Motor1,Motor1,DINT,Read/Write
|
||||
|
||||
Motor2,Motor2,DINT,Read/Write
|
||||
""";
|
||||
|
||||
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||
|
||||
result.Tags.Count.ShouldBe(2);
|
||||
result.Tags.Select(t => t.Name).ShouldBe(["Motor1", "Motor2"]);
|
||||
result.SkippedBlankCount.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quoted_field_with_embedded_comma_is_parsed()
|
||||
{
|
||||
const string csv = """
|
||||
Tag Name,Address,Data Type,Client Access,Description
|
||||
Motor1,Motor1,DINT,Read/Write,"Speed, RPM"
|
||||
""";
|
||||
|
||||
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||
|
||||
result.Tags.Single().Description.ShouldBe("Speed, RPM");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quoted_field_with_escaped_quote_is_parsed()
|
||||
{
|
||||
const string csv = "Tag Name,Address,Data Type,Client Access,Description\r\n"
|
||||
+ "Tag1,Tag1,DINT,Read Only,\"He said \"\"hi\"\"\"\r\n";
|
||||
|
||||
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||
|
||||
result.Tags.Single().Description.ShouldBe("He said \"hi\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamePrefix_is_applied()
|
||||
{
|
||||
const string csv = """
|
||||
Tag Name,Address,Data Type,Client Access
|
||||
Speed,Speed,DINT,Read/Write
|
||||
""";
|
||||
|
||||
var result = new CsvTagImporter
|
||||
{
|
||||
DefaultDeviceHostAddress = DeviceHost,
|
||||
NamePrefix = "PLC1_",
|
||||
}.Import(csv);
|
||||
|
||||
result.Tags.Single().Name.ShouldBe("PLC1_Speed");
|
||||
result.Tags.Single().TagPath.ShouldBe("Speed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_data_type_falls_through_as_Structure()
|
||||
{
|
||||
const string csv = """
|
||||
Tag Name,Address,Data Type,Client Access
|
||||
Mystery,Mystery,SomeUnknownType,Read/Write
|
||||
""";
|
||||
|
||||
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||
|
||||
result.Tags.Single().DataType.ShouldBe(AbCipDataType.Structure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throws_when_DefaultDeviceHostAddress_missing()
|
||||
{
|
||||
const string csv = "Tag Name,Address,Data Type,Client Access\nA,A,DINT,Read/Write\n";
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => new CsvTagImporter().Import(csv));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Round_trip_load_export_reparse_is_stable()
|
||||
{
|
||||
var original = new[]
|
||||
{
|
||||
new AbCipTagDefinition("Motor1", DeviceHost, "Motor1", AbCipDataType.DInt,
|
||||
Writable: true, Description: "Drive speed"),
|
||||
new AbCipTagDefinition("Sensor", DeviceHost, "Sensor", AbCipDataType.Real,
|
||||
Writable: false, Description: "Pressure, kPa"),
|
||||
new AbCipTagDefinition("Tag3", DeviceHost, "Program:Main.Tag3", AbCipDataType.Bool,
|
||||
Writable: true, Description: null),
|
||||
};
|
||||
|
||||
var csv = CsvTagExporter.ToCsv(original);
|
||||
var reparsed = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv).Tags;
|
||||
|
||||
reparsed.Count.ShouldBe(original.Length);
|
||||
for (var i = 0; i < original.Length; i++)
|
||||
{
|
||||
reparsed[i].Name.ShouldBe(original[i].Name);
|
||||
reparsed[i].TagPath.ShouldBe(original[i].TagPath);
|
||||
reparsed[i].DataType.ShouldBe(original[i].DataType);
|
||||
reparsed[i].Writable.ShouldBe(original[i].Writable);
|
||||
reparsed[i].Description.ShouldBe(original[i].Description);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reordered_columns_are_honoured_via_header_lookup()
|
||||
{
|
||||
const string csv = """
|
||||
Description,Address,Tag Name,Client Access,Data Type
|
||||
Drive speed,Motor1,Motor1,Read/Write,DINT
|
||||
""";
|
||||
|
||||
var result = new CsvTagImporter { DefaultDeviceHostAddress = DeviceHost }.Import(csv);
|
||||
|
||||
var t = result.Tags.Single();
|
||||
t.Name.ShouldBe("Motor1");
|
||||
t.TagPath.ShouldBe("Motor1");
|
||||
t.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
t.Description.ShouldBe("Drive speed");
|
||||
t.Writable.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
250
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
Normal file
250
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kIngestTests.cs
Normal file
@@ -0,0 +1,250 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class L5kIngestTests
|
||||
{
|
||||
private const string DeviceHost = "ab://10.10.10.1/0,1";
|
||||
|
||||
[Fact]
|
||||
public void Atomic_controller_scope_tag_becomes_AbCipTagDefinition()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Motor1_Speed : DINT (ExternalAccess := Read/Write) := 0;
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var ingest = new L5kIngest { DefaultDeviceHostAddress = DeviceHost };
|
||||
var result = ingest.Ingest(doc);
|
||||
|
||||
result.Tags.Count.ShouldBe(1);
|
||||
var tag = result.Tags[0];
|
||||
tag.Name.ShouldBe("Motor1_Speed");
|
||||
tag.DeviceHostAddress.ShouldBe(DeviceHost);
|
||||
tag.TagPath.ShouldBe("Motor1_Speed");
|
||||
tag.DataType.ShouldBe(AbCipDataType.DInt);
|
||||
tag.Writable.ShouldBeTrue();
|
||||
tag.Members.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_tag_uses_Program_prefix_and_compound_name()
|
||||
{
|
||||
const string body = """
|
||||
PROGRAM MainProgram
|
||||
TAG
|
||||
StepIndex : DINT := 0;
|
||||
END_TAG
|
||||
END_PROGRAM
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
result.Tags.Count.ShouldBe(1);
|
||||
result.Tags[0].Name.ShouldBe("MainProgram.StepIndex");
|
||||
result.Tags[0].TagPath.ShouldBe("Program:MainProgram.StepIndex");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alias_tag_is_skipped()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Real : DINT := 0;
|
||||
Aliased : DINT (AliasFor := "Real");
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
result.SkippedAliasCount.ShouldBe(1);
|
||||
result.Tags.Count.ShouldBe(1);
|
||||
result.Tags.ShouldAllBe(t => t.Name != "Aliased");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalAccess_None_tag_is_skipped()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Hidden : DINT (ExternalAccess := None) := 0;
|
||||
Visible : DINT := 0;
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
result.SkippedNoAccessCount.ShouldBe(1);
|
||||
result.Tags.Single().Name.ShouldBe("Visible");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExternalAccess_ReadOnly_tag_becomes_non_writable()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Sensor : REAL (ExternalAccess := Read Only) := 0.0;
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
result.Tags.Single().Writable.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UDT_typed_tag_picks_up_member_layout_from_DATATYPE_block()
|
||||
{
|
||||
const string body = """
|
||||
DATATYPE TankUDT
|
||||
MEMBER Level : REAL := 0.0;
|
||||
MEMBER Active : BOOL := 0;
|
||||
END_DATATYPE
|
||||
TAG
|
||||
Tank1 : TankUDT := [0.0, 0];
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
var tag = result.Tags.Single();
|
||||
tag.Name.ShouldBe("Tank1");
|
||||
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
tag.Members.ShouldNotBeNull();
|
||||
tag.Members!.Count.ShouldBe(2);
|
||||
tag.Members[0].Name.ShouldBe("Level");
|
||||
tag.Members[0].DataType.ShouldBe(AbCipDataType.Real);
|
||||
tag.Members[1].Name.ShouldBe("Active");
|
||||
tag.Members[1].DataType.ShouldBe(AbCipDataType.Bool);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_datatype_falls_through_as_structure_with_no_members()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Mystery : SomeUnknownType := 0;
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
var tag = result.Tags.Single();
|
||||
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
tag.Members.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ingest_throws_when_DefaultDeviceHostAddress_missing()
|
||||
{
|
||||
var doc = new L5kDocument(new[] { new L5kTag("X", "DINT", null, null, null, null) }, Array.Empty<L5kDataType>());
|
||||
|
||||
Should.Throw<InvalidOperationException>(() => new L5kIngest().Ingest(doc));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AOI_member_Usage_maps_to_AoiQualifier_through_ingest()
|
||||
{
|
||||
// PR abcip-2.6 — L5K AOI parameters carry a Usage := Input / Output / InOut attribute.
|
||||
// Ingest must map those values onto AbCipStructureMember.AoiQualifier so the discovery
|
||||
// layer can group AOI members under sub-folders. Plain DATATYPE members get Local.
|
||||
const string body = """
|
||||
ADD_ON_INSTRUCTION_DEFINITION ValveAoi
|
||||
PARAMETERS
|
||||
PARAMETER Cmd : BOOL (Usage := Input) := 0;
|
||||
PARAMETER Status : DINT (Usage := Output) := 0;
|
||||
PARAMETER Buffer : DINT (Usage := InOut) := 0;
|
||||
PARAMETER Local1 : DINT := 0;
|
||||
END_PARAMETERS
|
||||
END_ADD_ON_INSTRUCTION_DEFINITION
|
||||
DATATYPE PlainUdt
|
||||
MEMBER Speed : DINT := 0;
|
||||
END_DATATYPE
|
||||
TAG
|
||||
Valve_001 : ValveAoi;
|
||||
Tank1 : PlainUdt;
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
var aoiTag = result.Tags.Single(t => t.Name == "Valve_001");
|
||||
aoiTag.Members.ShouldNotBeNull();
|
||||
aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input);
|
||||
aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output);
|
||||
aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut);
|
||||
aoiTag.Members.Single(m => m.Name == "Local1").AoiQualifier.ShouldBe(AoiQualifier.Local);
|
||||
|
||||
// Plain UDT members default to Local — no Usage attribute to map.
|
||||
var plainTag = result.Tags.Single(t => t.Name == "Tank1");
|
||||
plainTag.Members.ShouldNotBeNull();
|
||||
plainTag.Members!.Single().AoiQualifier.ShouldBe(AoiQualifier.Local);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void L5x_AOI_member_Usage_maps_to_AoiQualifier_through_ingest()
|
||||
{
|
||||
// Same mapping as the L5K case above, exercised through the L5X parser to confirm both
|
||||
// formats land at the same downstream representation.
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<AddOnInstructionDefinitions>
|
||||
<AddOnInstructionDefinition Name="MyAoi">
|
||||
<Parameters>
|
||||
<Parameter Name="Cmd" DataType="BOOL" Usage="Input" />
|
||||
<Parameter Name="Status" DataType="DINT" Usage="Output" />
|
||||
<Parameter Name="Buffer" DataType="DINT" Usage="InOut" />
|
||||
</Parameters>
|
||||
</AddOnInstructionDefinition>
|
||||
</AddOnInstructionDefinitions>
|
||||
<Tags>
|
||||
<Tag Name="Valve_001" TagType="Base" DataType="MyAoi" />
|
||||
</Tags>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = DeviceHost }.Ingest(doc);
|
||||
|
||||
var aoiTag = result.Tags.Single();
|
||||
aoiTag.Members.ShouldNotBeNull();
|
||||
aoiTag.Members!.Single(m => m.Name == "Cmd").AoiQualifier.ShouldBe(AoiQualifier.Input);
|
||||
aoiTag.Members.Single(m => m.Name == "Status").AoiQualifier.ShouldBe(AoiQualifier.Output);
|
||||
aoiTag.Members.Single(m => m.Name == "Buffer").AoiQualifier.ShouldBe(AoiQualifier.InOut);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NamePrefix_is_applied_to_imported_tags()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Speed : DINT := 0;
|
||||
END_TAG
|
||||
""";
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var result = new L5kIngest
|
||||
{
|
||||
DefaultDeviceHostAddress = DeviceHost,
|
||||
NamePrefix = "PLC1_",
|
||||
}.Ingest(doc);
|
||||
|
||||
result.Tags.Single().Name.ShouldBe("PLC1_Speed");
|
||||
result.Tags.Single().TagPath.ShouldBe("Speed"); // path on the PLC stays unchanged
|
||||
}
|
||||
}
|
||||
198
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
Normal file
198
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5kParserTests.cs
Normal file
@@ -0,0 +1,198 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class L5kParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Controller_scope_TAG_block_parses_name_datatype_externalaccess()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Motor1_Speed : DINT (Description := "Motor 1 set point", ExternalAccess := Read/Write) := 0;
|
||||
Tank_Level : REAL (ExternalAccess := Read Only) := 0.0;
|
||||
END_TAG
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
doc.Tags.Count.ShouldBe(2);
|
||||
doc.Tags[0].Name.ShouldBe("Motor1_Speed");
|
||||
doc.Tags[0].DataType.ShouldBe("DINT");
|
||||
doc.Tags[0].ProgramScope.ShouldBeNull();
|
||||
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
|
||||
doc.Tags[0].Description.ShouldBe("Motor 1 set point");
|
||||
doc.Tags[0].AliasFor.ShouldBeNull();
|
||||
|
||||
doc.Tags[1].Name.ShouldBe("Tank_Level");
|
||||
doc.Tags[1].DataType.ShouldBe("REAL");
|
||||
doc.Tags[1].ExternalAccess.ShouldBe("Read Only");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_TAG_block_carries_program_name()
|
||||
{
|
||||
const string body = """
|
||||
PROGRAM MainProgram (Class := Standard)
|
||||
TAG
|
||||
StepIndex : DINT := 0;
|
||||
Running : BOOL := 0;
|
||||
END_TAG
|
||||
END_PROGRAM
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
doc.Tags.Count.ShouldBe(2);
|
||||
doc.Tags.ShouldAllBe(t => t.ProgramScope == "MainProgram");
|
||||
doc.Tags.Select(t => t.Name).ShouldBe(["StepIndex", "Running"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alias_tag_is_flagged()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Motor1 : DINT := 0;
|
||||
Motor1_Alias : DINT (AliasFor := "Motor1", ExternalAccess := Read/Write);
|
||||
END_TAG
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var alias = doc.Tags.Single(t => t.Name == "Motor1_Alias");
|
||||
alias.AliasFor.ShouldBe("Motor1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DATATYPE_block_collects_member_lines()
|
||||
{
|
||||
const string body = """
|
||||
DATATYPE TankUDT (FamilyType := NoFamily)
|
||||
MEMBER Level : REAL (ExternalAccess := Read/Write) := 0.0;
|
||||
MEMBER Pressure : REAL := 0.0;
|
||||
MEMBER Active : BOOL := 0;
|
||||
END_DATATYPE
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
doc.DataTypes.Count.ShouldBe(1);
|
||||
var udt = doc.DataTypes[0];
|
||||
udt.Name.ShouldBe("TankUDT");
|
||||
udt.Members.Count.ShouldBe(3);
|
||||
udt.Members[0].Name.ShouldBe("Level");
|
||||
udt.Members[0].DataType.ShouldBe("REAL");
|
||||
udt.Members[0].ExternalAccess.ShouldBe("Read/Write");
|
||||
udt.Members[1].Name.ShouldBe("Pressure");
|
||||
udt.Members[2].Name.ShouldBe("Active");
|
||||
udt.Members[2].DataType.ShouldBe("BOOL");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DATATYPE_member_with_array_dim_keeps_type_clean()
|
||||
{
|
||||
const string body = """
|
||||
DATATYPE BatchUDT
|
||||
MEMBER Recipe : DINT[16] := 0;
|
||||
MEMBER Name : STRING := "";
|
||||
END_DATATYPE
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var udt = doc.DataTypes[0];
|
||||
var recipe = udt.Members.First(m => m.Name == "Recipe");
|
||||
recipe.DataType.ShouldBe("DINT");
|
||||
recipe.ArrayDim.ShouldBe(16);
|
||||
|
||||
var nameMember = udt.Members.First(m => m.Name == "Name");
|
||||
nameMember.DataType.ShouldBe("STRING");
|
||||
nameMember.ArrayDim.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Block_comments_are_stripped_before_parsing()
|
||||
{
|
||||
const string body = """
|
||||
(* This is a long
|
||||
multi-line comment with TAG and END_TAG inside, parser must skip *)
|
||||
TAG
|
||||
Real_Tag : DINT := 0;
|
||||
END_TAG
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
doc.Tags.Count.ShouldBe(1);
|
||||
doc.Tags[0].Name.ShouldBe("Real_Tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_sections_are_skipped_silently()
|
||||
{
|
||||
const string body = """
|
||||
CONFIG SomeConfig (Class := Standard)
|
||||
ConfigData := 0;
|
||||
END_CONFIG
|
||||
MOTION_GROUP Motion1
|
||||
Member := whatever;
|
||||
END_MOTION_GROUP
|
||||
TAG
|
||||
Real_Tag : DINT := 0;
|
||||
END_TAG
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
doc.Tags.Count.ShouldBe(1);
|
||||
doc.Tags[0].Name.ShouldBe("Real_Tag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AOI_definition_block_collects_parameters_with_Usage()
|
||||
{
|
||||
// PR abcip-2.6 — ADD_ON_INSTRUCTION_DEFINITION blocks with PARAMETER entries carrying
|
||||
// Usage := Input / Output / InOut. The parser surfaces them as L5kDataType members so
|
||||
// AOI-typed tags pick up a layout the same way UDT-typed tags do.
|
||||
const string body = """
|
||||
ADD_ON_INSTRUCTION_DEFINITION MyValveAoi (Revision := "1.0")
|
||||
PARAMETERS
|
||||
PARAMETER Cmd : BOOL (Usage := Input) := 0;
|
||||
PARAMETER Status : DINT (Usage := Output, ExternalAccess := Read Only) := 0;
|
||||
PARAMETER Buffer : DINT (Usage := InOut) := 0;
|
||||
PARAMETER Internal : DINT := 0;
|
||||
END_PARAMETERS
|
||||
LOCAL_TAGS
|
||||
Working : DINT := 0;
|
||||
END_LOCAL_TAGS
|
||||
END_ADD_ON_INSTRUCTION_DEFINITION
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
|
||||
var aoi = doc.DataTypes.Single(d => d.Name == "MyValveAoi");
|
||||
aoi.Members.Count.ShouldBe(4);
|
||||
aoi.Members.Single(m => m.Name == "Cmd").Usage.ShouldBe("Input");
|
||||
aoi.Members.Single(m => m.Name == "Status").Usage.ShouldBe("Output");
|
||||
aoi.Members.Single(m => m.Name == "Buffer").Usage.ShouldBe("InOut");
|
||||
aoi.Members.Single(m => m.Name == "Internal").Usage.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multi_line_TAG_entry_is_concatenated()
|
||||
{
|
||||
const string body = """
|
||||
TAG
|
||||
Motor1 : DINT (Description := "Long description spanning",
|
||||
ExternalAccess := Read/Write) := 0;
|
||||
END_TAG
|
||||
""";
|
||||
|
||||
var doc = L5kParser.Parse(new StringL5kSource(body));
|
||||
doc.Tags.Count.ShouldBe(1);
|
||||
doc.Tags[0].Description.ShouldBe("Long description spanning");
|
||||
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
|
||||
}
|
||||
}
|
||||
259
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs
Normal file
259
tests/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/L5xParserTests.cs
Normal file
@@ -0,0 +1,259 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.Import;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class L5xParserTests
|
||||
{
|
||||
[Fact]
|
||||
public void Controller_scope_Tag_elements_parse_with_metadata()
|
||||
{
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content SchemaRevision="1.0" SoftwareRevision="32.00">
|
||||
<Controller Name="MyController" ProcessorType="1756-L83E">
|
||||
<Tags>
|
||||
<Tag Name="Motor1_Speed" TagType="Base" DataType="DINT" ExternalAccess="Read/Write">
|
||||
<Description><![CDATA[Motor 1 set point]]></Description>
|
||||
</Tag>
|
||||
<Tag Name="Tank_Level" TagType="Base" DataType="REAL" ExternalAccess="Read Only" />
|
||||
</Tags>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
|
||||
doc.Tags.Count.ShouldBe(2);
|
||||
doc.Tags[0].Name.ShouldBe("Motor1_Speed");
|
||||
doc.Tags[0].DataType.ShouldBe("DINT");
|
||||
doc.Tags[0].ExternalAccess.ShouldBe("Read/Write");
|
||||
doc.Tags[0].Description.ShouldBe("Motor 1 set point");
|
||||
doc.Tags[0].ProgramScope.ShouldBeNull();
|
||||
doc.Tags[0].AliasFor.ShouldBeNull();
|
||||
|
||||
doc.Tags[1].Name.ShouldBe("Tank_Level");
|
||||
doc.Tags[1].ExternalAccess.ShouldBe("Read Only");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Program_scope_Tag_elements_carry_program_name()
|
||||
{
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<Programs>
|
||||
<Program Name="MainProgram" Class="Standard">
|
||||
<Tags>
|
||||
<Tag Name="StepIndex" TagType="Base" DataType="DINT" />
|
||||
<Tag Name="Running" TagType="Base" DataType="BOOL" />
|
||||
</Tags>
|
||||
</Program>
|
||||
</Programs>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
|
||||
doc.Tags.Count.ShouldBe(2);
|
||||
doc.Tags.ShouldAllBe(t => t.ProgramScope == "MainProgram");
|
||||
doc.Tags.Select(t => t.Name).ShouldBe(["StepIndex", "Running"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Alias_tag_carries_AliasFor_and_is_skipped_on_ingest()
|
||||
{
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<Tags>
|
||||
<Tag Name="Real" TagType="Base" DataType="DINT" />
|
||||
<Tag Name="Aliased" TagType="Alias" AliasFor="Real" ExternalAccess="Read/Write" />
|
||||
</Tags>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
var alias = doc.Tags.Single(t => t.Name == "Aliased");
|
||||
alias.AliasFor.ShouldBe("Real");
|
||||
|
||||
var ingestResult = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
|
||||
ingestResult.SkippedAliasCount.ShouldBe(1);
|
||||
ingestResult.Tags.ShouldAllBe(t => t.Name != "Aliased");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DataType_block_collects_member_elements_and_skips_hidden_zzzz_host()
|
||||
{
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<DataTypes>
|
||||
<DataType Name="TankUDT" Class="User">
|
||||
<Members>
|
||||
<Member Name="ZZZZZZZZZZTankUDT0" DataType="SINT" Hidden="true" />
|
||||
<Member Name="Level" DataType="REAL" ExternalAccess="Read/Write" />
|
||||
<Member Name="Active" DataType="BIT" Target="ZZZZZZZZZZTankUDT0" BitNumber="0" />
|
||||
</Members>
|
||||
</DataType>
|
||||
</DataTypes>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
|
||||
doc.DataTypes.Count.ShouldBe(1);
|
||||
var udt = doc.DataTypes[0];
|
||||
udt.Name.ShouldBe("TankUDT");
|
||||
udt.Members.Count.ShouldBe(2);
|
||||
udt.Members.ShouldContain(m => m.Name == "Level" && m.DataType == "REAL");
|
||||
udt.Members.ShouldContain(m => m.Name == "Active");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UDT_typed_tag_picks_up_member_layout_through_ingest()
|
||||
{
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<DataTypes>
|
||||
<DataType Name="TankUDT" Class="User">
|
||||
<Members>
|
||||
<Member Name="Level" DataType="REAL" />
|
||||
<Member Name="Pressure" DataType="REAL" />
|
||||
</Members>
|
||||
</DataType>
|
||||
</DataTypes>
|
||||
<Tags>
|
||||
<Tag Name="Tank1" TagType="Base" DataType="TankUDT" />
|
||||
</Tags>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
|
||||
|
||||
var tag = result.Tags.Single();
|
||||
tag.Name.ShouldBe("Tank1");
|
||||
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
tag.Members.ShouldNotBeNull();
|
||||
tag.Members!.Count.ShouldBe(2);
|
||||
tag.Members.Select(m => m.Name).ShouldBe(["Level", "Pressure"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AOI_definition_surfaces_as_datatype_with_visible_parameters()
|
||||
{
|
||||
// EnableIn / EnableOut on real exports carry Hidden="true" — the parser must skip those
|
||||
// so AOI-typed tags don't end up with phantom EnableIn/EnableOut members.
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<AddOnInstructionDefinitions>
|
||||
<AddOnInstructionDefinition Name="MyValveAoi" Revision="1.0">
|
||||
<Parameters>
|
||||
<Parameter Name="EnableIn" TagType="Base" DataType="BOOL" Usage="Input" Hidden="true" />
|
||||
<Parameter Name="EnableOut" TagType="Base" DataType="BOOL" Usage="Output" Hidden="true" />
|
||||
<Parameter Name="Cmd" TagType="Base" DataType="BOOL" Usage="Input" />
|
||||
<Parameter Name="Status" TagType="Base" DataType="DINT" Usage="Output" ExternalAccess="Read Only" />
|
||||
</Parameters>
|
||||
</AddOnInstructionDefinition>
|
||||
</AddOnInstructionDefinitions>
|
||||
<Tags>
|
||||
<Tag Name="Valve_001" TagType="Base" DataType="MyValveAoi" />
|
||||
</Tags>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
// AOI definition should appear as a "DataType" entry alongside any UDTs.
|
||||
var aoi = doc.DataTypes.Single(d => d.Name == "MyValveAoi");
|
||||
aoi.Members.Count.ShouldBe(2);
|
||||
aoi.Members.Select(m => m.Name).ShouldBe(["Cmd", "Status"]);
|
||||
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
|
||||
var tag = result.Tags.Single();
|
||||
tag.Name.ShouldBe("Valve_001");
|
||||
tag.DataType.ShouldBe(AbCipDataType.Structure);
|
||||
tag.Members.ShouldNotBeNull();
|
||||
tag.Members!.Select(m => m.Name).ShouldBe(["Cmd", "Status"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AOI_parameter_Usage_attribute_is_captured()
|
||||
{
|
||||
// PR abcip-2.6 — Usage attribute on <Parameter> elements (Input / Output / InOut) flows
|
||||
// through to L5kMember.Usage so the ingest layer can map it to AoiQualifier.
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<AddOnInstructionDefinitions>
|
||||
<AddOnInstructionDefinition Name="MyAoi" Revision="1.0">
|
||||
<Parameters>
|
||||
<Parameter Name="Cmd" DataType="BOOL" Usage="Input" />
|
||||
<Parameter Name="Status" DataType="DINT" Usage="Output" />
|
||||
<Parameter Name="Buffer" DataType="DINT" Usage="InOut" />
|
||||
</Parameters>
|
||||
</AddOnInstructionDefinition>
|
||||
</AddOnInstructionDefinitions>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
var aoi = doc.DataTypes.Single(d => d.Name == "MyAoi");
|
||||
aoi.Members.Single(m => m.Name == "Cmd").Usage.ShouldBe("Input");
|
||||
aoi.Members.Single(m => m.Name == "Status").Usage.ShouldBe("Output");
|
||||
aoi.Members.Single(m => m.Name == "Buffer").Usage.ShouldBe("InOut");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_or_minimal_document_returns_empty_bundle_without_throwing()
|
||||
{
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C" />
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
doc.Tags.Count.ShouldBe(0);
|
||||
doc.DataTypes.Count.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_external_access_defaults_to_writable_through_ingest()
|
||||
{
|
||||
// L5X: ExternalAccess attribute absent → ingest treats as default (writable, not skipped).
|
||||
const string body = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<RSLogix5000Content>
|
||||
<Controller Name="C">
|
||||
<Tags>
|
||||
<Tag Name="Plain" TagType="Base" DataType="DINT" />
|
||||
</Tags>
|
||||
</Controller>
|
||||
</RSLogix5000Content>
|
||||
""";
|
||||
|
||||
var doc = L5xParser.Parse(new StringL5kSource(body));
|
||||
var result = new L5kIngest { DefaultDeviceHostAddress = "ab://10.0.0.1/0,1" }.Ingest(doc);
|
||||
|
||||
result.Tags.Single().Writable.ShouldBeTrue();
|
||||
result.SkippedNoAccessCount.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
@@ -65,4 +66,437 @@ public sealed class AbLegacyAddressTests
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe(input);
|
||||
}
|
||||
|
||||
// ---- PLC-5 octal I:/O: addressing (Issue #244) ----
|
||||
//
|
||||
// RSLogix 5 displays I:/O: word + bit indices as octal. `I:001/17` means rack 1, bit 15
|
||||
// (octal 17). Other PCCC families (SLC500, MicroLogix, LogixPccc) keep decimal indices.
|
||||
// Non-I/O file letters are always decimal regardless of family.
|
||||
|
||||
[Theory]
|
||||
[InlineData("I:001/17", 1, 15)] // octal 17 → bit 15
|
||||
[InlineData("I:0/0", 0, 0)] // boundary: octal 0
|
||||
[InlineData("O:1/2", 1, 2)] // octal 1, 2 happen to match decimal
|
||||
[InlineData("I:010/10", 8, 8)] // octal 10 → 8 (both word + bit)
|
||||
[InlineData("I:007/7", 7, 7)] // boundary: largest single octal digit
|
||||
public void TryParse_Plc5_parses_io_indices_as_octal(string input, int expectedWord, int expectedBit)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
|
||||
a.ShouldNotBeNull();
|
||||
a.WordNumber.ShouldBe(expectedWord);
|
||||
a.BitIndex.ShouldBe(expectedBit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("I:8/0")] // word digit 8 illegal in octal
|
||||
[InlineData("I:0/9")] // bit digit 9 illegal in octal
|
||||
[InlineData("O:128/0")] // contains digit 8
|
||||
[InlineData("I:0/18")] // bit field octal-illegal because of '8'
|
||||
public void TryParse_Plc5_rejects_octal_invalid_io_digits(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Non-I/O files stay decimal even on PLC-5 (e.g. N7:8 is integer 7, word 8).
|
||||
[InlineData("N7:8", 7, 8)]
|
||||
[InlineData("F8:9", 8, 9)]
|
||||
public void TryParse_Plc5_keeps_non_io_indices_decimal(string input, int? expectedFile, int expectedWord)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
|
||||
a.ShouldNotBeNull();
|
||||
a.FileNumber.ShouldBe(expectedFile);
|
||||
a.WordNumber.ShouldBe(expectedWord);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Slc500_keeps_io_indices_decimal_back_compat()
|
||||
{
|
||||
// SLC500 has OctalIoAddressing=false — the digits are decimal as before.
|
||||
var a = AbLegacyAddress.TryParse("I:10/15", AbLegacyPlcFamily.Slc500);
|
||||
a.ShouldNotBeNull();
|
||||
a.WordNumber.ShouldBe(10);
|
||||
a.BitIndex.ShouldBe(15);
|
||||
|
||||
// Decimal '8' that PLC-5 would reject is fine on SLC500.
|
||||
var b = AbLegacyAddress.TryParse("I:8/0", AbLegacyPlcFamily.Slc500);
|
||||
b.ShouldNotBeNull();
|
||||
b.WordNumber.ShouldBe(8);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_MicroLogix_and_LogixPccc_keep_io_indices_decimal()
|
||||
{
|
||||
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.MicroLogix).ShouldNotBeNull();
|
||||
AbLegacyAddress.TryParse("I:9/0", AbLegacyPlcFamily.LogixPccc).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plc5Profile_advertises_octal_io_addressing()
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.Plc5.OctalIoAddressing.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.OctalIoAddressing.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.OctalIoAddressing.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.OctalIoAddressing.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- MicroLogix function-file letters (Issue #245) ----
|
||||
//
|
||||
// MicroLogix 1100/1400 expose RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI function files. Other
|
||||
// PCCC families (SLC500 / PLC-5 / LogixPccc) reject those file letters.
|
||||
|
||||
[Theory]
|
||||
[InlineData("RTC:0.HR", "RTC", "HR")]
|
||||
[InlineData("RTC:0.MIN", "RTC", "MIN")]
|
||||
[InlineData("RTC:0.YR", "RTC", "YR")]
|
||||
[InlineData("HSC:0.ACC", "HSC", "ACC")]
|
||||
[InlineData("HSC:0.PRE", "HSC", "PRE")]
|
||||
[InlineData("HSC:0.EN", "HSC", "EN")]
|
||||
[InlineData("DLS:0.STR", "DLS", "STR")]
|
||||
[InlineData("PTO:0.OF", "PTO", "OF")]
|
||||
[InlineData("PWM:0.EN", "PWM", "EN")]
|
||||
[InlineData("STI:0.SPM", "STI", "SPM")]
|
||||
[InlineData("EII:0.PFN", "EII", "PFN")]
|
||||
[InlineData("MMI:0.FT", "MMI", "FT")]
|
||||
[InlineData("BHI:0.OS", "BHI", "OS")]
|
||||
[InlineData("IOS:0.ID", "IOS", "ID")]
|
||||
public void TryParse_MicroLogix_accepts_function_files(string input, string expectedLetter, string expectedSub)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.MicroLogix);
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe(expectedLetter);
|
||||
a.SubElement.ShouldBe(expectedSub);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RTC:0.HR")]
|
||||
[InlineData("HSC:0.ACC")]
|
||||
[InlineData("PTO:0.OF")]
|
||||
[InlineData("BHI:0.OS")]
|
||||
public void TryParse_Slc500_rejects_function_files(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Slc500).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RTC:0.HR")]
|
||||
[InlineData("HSC:0.ACC")]
|
||||
public void TryParse_Plc5_and_LogixPccc_reject_function_files(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5).ShouldBeNull();
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.LogixPccc).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Default_overload_rejects_function_files()
|
||||
{
|
||||
// Without a family the parser cannot allow MicroLogix-only letters — back-compat with
|
||||
// the family-less overload from before #244.
|
||||
AbLegacyAddress.TryParse("RTC:0.HR").ShouldBeNull();
|
||||
AbLegacyAddress.TryParse("HSC:0.ACC").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MicroLogixProfile_advertises_function_file_support()
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsFunctionFiles.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsFunctionFiles.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsFunctionFiles.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsFunctionFiles.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- Indirect / indexed addressing (Issue #247) ----
|
||||
//
|
||||
// PLC-5 / SLC permit `N7:[N7:0]` (word number sourced from another address) and
|
||||
// `N[N7:0]:5` (file number sourced from another address). Recursion is capped at 1 — the
|
||||
// inner address must itself be a plain direct PCCC reference.
|
||||
|
||||
[Fact]
|
||||
public void TryParse_accepts_indirect_word_source()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N7:[N7:0]");
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe("N");
|
||||
a.FileNumber.ShouldBe(7);
|
||||
a.IndirectFileSource.ShouldBeNull();
|
||||
a.IndirectWordSource.ShouldNotBeNull();
|
||||
a.IndirectWordSource!.FileLetter.ShouldBe("N");
|
||||
a.IndirectWordSource.FileNumber.ShouldBe(7);
|
||||
a.IndirectWordSource.WordNumber.ShouldBe(0);
|
||||
a.IsIndirect.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_accepts_indirect_file_source()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N[N7:0]:5");
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe("N");
|
||||
a.FileNumber.ShouldBeNull();
|
||||
a.WordNumber.ShouldBe(5);
|
||||
a.IndirectFileSource.ShouldNotBeNull();
|
||||
a.IndirectFileSource!.FileLetter.ShouldBe("N");
|
||||
a.IndirectFileSource.FileNumber.ShouldBe(7);
|
||||
a.IndirectFileSource.WordNumber.ShouldBe(0);
|
||||
a.IndirectWordSource.ShouldBeNull();
|
||||
a.IsIndirect.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_accepts_both_indirect_file_and_word()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N[N7:0]:[N7:1]");
|
||||
a.ShouldNotBeNull();
|
||||
a.IndirectFileSource.ShouldNotBeNull();
|
||||
a.IndirectWordSource.ShouldNotBeNull();
|
||||
a.IndirectWordSource!.WordNumber.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("N[N[N7:0]:0]:5")] // depth-2 file source
|
||||
[InlineData("N7:[N[N7:0]:0]")] // depth-2 word source
|
||||
[InlineData("N7:[N7:[N7:0]]")] // depth-2 word source (nested word)
|
||||
public void TryParse_rejects_depth_greater_than_one(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("N7:[")] // unbalanced bracket
|
||||
[InlineData("N7:]")] // unbalanced bracket
|
||||
[InlineData("N[:5")] // empty inner file source
|
||||
[InlineData("N7:[]")] // empty inner word source
|
||||
[InlineData("N[X9:0]:5")] // unknown file letter inside
|
||||
public void TryParse_rejects_malformed_indirect(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLibplctagName_reemits_indirect_word_source()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N7:[N7:0]");
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe("N7:[N7:0]");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToLibplctagName_reemits_indirect_file_source()
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse("N[N7:0]:5");
|
||||
a.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe("N[N7:0]:5");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_indirect_with_bit_outside_brackets()
|
||||
{
|
||||
// Outer bit applies to the resolved word; inner address is still depth-1.
|
||||
var a = AbLegacyAddress.TryParse("N7:[N7:0]/3");
|
||||
a.ShouldNotBeNull();
|
||||
a.BitIndex.ShouldBe(3);
|
||||
a.IndirectWordSource.ShouldNotBeNull();
|
||||
a.ToLibplctagName().ShouldBe("N7:[N7:0]/3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Plc5_indirect_inner_address_obeys_octal()
|
||||
{
|
||||
// Inner I:/O: indices on PLC-5 must obey octal rules even when nested in brackets.
|
||||
var a = AbLegacyAddress.TryParse("N7:[I:010/10]", AbLegacyPlcFamily.Plc5);
|
||||
a.ShouldNotBeNull();
|
||||
a.IndirectWordSource.ShouldNotBeNull();
|
||||
a.IndirectWordSource!.WordNumber.ShouldBe(8); // octal 010 → 8
|
||||
a.IndirectWordSource.BitIndex.ShouldBe(8); // octal 10 → 8
|
||||
|
||||
// Octal-illegal digit '8' inside an inner I: address is rejected on PLC-5.
|
||||
AbLegacyAddress.TryParse("N7:[I:8/0]", AbLegacyPlcFamily.Plc5).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_indirect_inner_cannot_itself_be_indirect()
|
||||
{
|
||||
AbLegacyAddress.TryParse("N7:[N7:[N7:0]]").ShouldBeNull();
|
||||
AbLegacyAddress.TryParse("N[N[N7:0]:5]:5").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RTC", "HR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData("RTC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData("HSC", "ACC", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData("HSC", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData("DLS", "STR", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData("DLS", "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData("PWM", "OUT", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
public void FunctionFile_subelement_catalogue_maps_to_expected_driver_type(
|
||||
string letter, string sub, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType expected)
|
||||
{
|
||||
AbLegacyFunctionFile.SubElementType(letter, sub).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- Structure files PD/MG/PLS/BT (Issue #248) ----
|
||||
//
|
||||
// PD (PID), MG (Message), PLS (Programmable Limit Switch), BT (Block Transfer) — accepted on
|
||||
// SLC500 + PLC-5 for PD/MG, PLC-5 only for PLS/BT. MicroLogix and LogixPccc reject all four.
|
||||
|
||||
[Theory]
|
||||
[InlineData("PD10:0.SP")]
|
||||
[InlineData("PD10:0.PV")]
|
||||
[InlineData("PD10:0.KP")]
|
||||
[InlineData("PD10:0.EN")]
|
||||
[InlineData("MG11:0.LEN")]
|
||||
[InlineData("MG11:0.DN")]
|
||||
public void TryParse_Slc500_accepts_pd_and_mg(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Slc500).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PLS12:0.LEN")]
|
||||
[InlineData("BT13:0.RLEN")]
|
||||
[InlineData("BT13:0.EN")]
|
||||
public void TryParse_Slc500_rejects_pls_and_bt(string input)
|
||||
{
|
||||
// PLS/BT are PLC-5 only; SLC500 must reject.
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Slc500).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PD10:0.KP", "PD", "KP")]
|
||||
[InlineData("MG11:0.EN", "MG", "EN")]
|
||||
[InlineData("PLS12:0.LEN", "PLS", "LEN")]
|
||||
[InlineData("BT13:0.RLEN", "BT", "RLEN")]
|
||||
[InlineData("BT13:0.DN", "BT", "DN")]
|
||||
public void TryParse_Plc5_accepts_all_structure_files(string input, string letter, string sub)
|
||||
{
|
||||
var a = AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.Plc5);
|
||||
a.ShouldNotBeNull();
|
||||
a.FileLetter.ShouldBe(letter);
|
||||
a.SubElement.ShouldBe(sub);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PD10:0.SP")]
|
||||
[InlineData("MG11:0.LEN")]
|
||||
[InlineData("PLS12:0.LEN")]
|
||||
[InlineData("BT13:0.RLEN")]
|
||||
public void TryParse_MicroLogix_rejects_all_structure_files(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.MicroLogix).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PD10:0.SP")]
|
||||
[InlineData("MG11:0.LEN")]
|
||||
[InlineData("PLS12:0.LEN")]
|
||||
[InlineData("BT13:0.RLEN")]
|
||||
public void TryParse_LogixPccc_rejects_all_structure_files(string input)
|
||||
{
|
||||
AbLegacyAddress.TryParse(input, AbLegacyPlcFamily.LogixPccc).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_Default_overload_rejects_structure_files()
|
||||
{
|
||||
// Without a family the parser cannot allow structure-file letters.
|
||||
AbLegacyAddress.TryParse("PD10:0.SP").ShouldBeNull();
|
||||
AbLegacyAddress.TryParse("MG11:0.LEN").ShouldBeNull();
|
||||
AbLegacyAddress.TryParse("PLS12:0.LEN").ShouldBeNull();
|
||||
AbLegacyAddress.TryParse("BT13:0.RLEN").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Profiles_advertise_structure_file_support_per_family()
|
||||
{
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsPidFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsMessageFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsPlsFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.Slc500.SupportsBlockTransferFile.ShouldBeFalse();
|
||||
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsPidFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsMessageFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsPlsFile.ShouldBeTrue();
|
||||
AbLegacyPlcFamilyProfile.Plc5.SupportsBlockTransferFile.ShouldBeTrue();
|
||||
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsPidFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsMessageFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsPlsFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.MicroLogix.SupportsBlockTransferFile.ShouldBeFalse();
|
||||
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsPidFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsMessageFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsPlsFile.ShouldBeFalse();
|
||||
AbLegacyPlcFamilyProfile.LogixPccc.SupportsBlockTransferFile.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// PID Float members.
|
||||
[InlineData(AbLegacyDataType.PidElement, "SP", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float32)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "PV", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float32)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "KP", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float32)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "KI", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float32)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "KD", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float32)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "OUT", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float32)]
|
||||
// PID status bits.
|
||||
[InlineData(AbLegacyDataType.PidElement, "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "DN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "MO", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "PE", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
// MG Int32 control words.
|
||||
[InlineData(AbLegacyDataType.MessageElement, "RBE", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "LEN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
// MG status bits.
|
||||
[InlineData(AbLegacyDataType.MessageElement, "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "DN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "TO", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
// PLS LEN.
|
||||
[InlineData(AbLegacyDataType.PlsElement, "LEN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
// BT control words + status bits.
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "RLEN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "DLEN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "EN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "DN", ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Boolean)]
|
||||
public void Structure_subelements_resolve_to_expected_driver_type(
|
||||
AbLegacyDataType type, string sub, ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType expected)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(type, sub).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// PD bits in word 0.
|
||||
[InlineData(AbLegacyDataType.PidElement, "EN", 0)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "PE", 1)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "DN", 2)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "MO", 3)]
|
||||
// MG/BT share the same 8..15 layout.
|
||||
[InlineData(AbLegacyDataType.MessageElement, "TO", 8)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "EN", 15)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "TO", 8)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "EN", 15)]
|
||||
public void Structure_status_bit_indices_match_rockwell(
|
||||
AbLegacyDataType type, string sub, int expectedBit)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(type, sub).ShouldBe(expectedBit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// PD: PE + DN + SP_VAL/SP_LL/SP_HL are PLC-set (read-only); EN + MO + AUTO + MAN are
|
||||
// operator-controllable.
|
||||
[InlineData(AbLegacyDataType.PidElement, "PE", true)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "SP_VAL", true)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "EN", false)]
|
||||
[InlineData(AbLegacyDataType.PidElement, "MO", false)]
|
||||
// MG/BT: ST/DN/ER/CO/EW/NR/TO are PLC-set; EN is operator-driven.
|
||||
[InlineData(AbLegacyDataType.MessageElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "ER", true)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "TO", true)]
|
||||
[InlineData(AbLegacyDataType.MessageElement, "EN", false)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.BlockTransferElement, "EN", false)]
|
||||
public void Structure_plc_set_status_bits_are_marked_read_only(
|
||||
AbLegacyDataType type, string sub, bool expected)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.IsPlcSetStatusBit(type, sub).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,4 +102,96 @@ public sealed class AbLegacyDriverTests
|
||||
AbLegacyDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
AbLegacyDataType.TimerElement.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "EN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "TT", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "DN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "PRE", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "ACC", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CU", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CD", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "DN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "OV", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "UN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "PRE", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "ACC", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EU", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "DN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EM", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "ER", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "UL", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "IN", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "FD", DriverDataType.Boolean)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "LEN", DriverDataType.Int32)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "POS", DriverDataType.Int32)]
|
||||
public void EffectiveDriverDataType_resolves_subelements(
|
||||
AbLegacyDataType dataType, string subElement, DriverDataType expected)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(dataType, subElement).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveDriverDataType_unknown_subelement_falls_back_to_base()
|
||||
{
|
||||
// Permissive — keeps the driver from refusing tags whose sub-element we don't catalogue.
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.TimerElement, "BOGUS")
|
||||
.ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.TimerElement, null)
|
||||
.ShouldBe(DriverDataType.Int32);
|
||||
AbLegacyDataTypeExtensions.EffectiveDriverDataType(AbLegacyDataType.Int, "DN")
|
||||
.ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "DN", 13)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "TT", 14)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "EN", 15)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "UN", 10)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "OV", 11)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "DN", 12)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CD", 13)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CU", 14)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "FD", 8)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "IN", 9)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "UL", 10)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "ER", 11)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EM", 12)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "DN", 13)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EU", 14)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EN", 15)]
|
||||
public void StatusBitIndex_maps_to_standard_pccc_positions(
|
||||
AbLegacyDataType dataType, string subElement, int expectedBit)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(dataType, subElement).ShouldBe(expectedBit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusBitIndex_for_word_subelements_is_null()
|
||||
{
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.TimerElement, "PRE").ShouldBeNull();
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.CounterElement, "ACC").ShouldBeNull();
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.ControlElement, "LEN").ShouldBeNull();
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.TimerElement, null).ShouldBeNull();
|
||||
AbLegacyDataTypeExtensions.StatusBitIndex(AbLegacyDataType.Int, "DN").ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "TT", true)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, "EN", false)] // operator-controllable
|
||||
[InlineData(AbLegacyDataType.CounterElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "OV", true)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "UN", true)]
|
||||
[InlineData(AbLegacyDataType.CounterElement, "CU", false)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "DN", true)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "ER", true)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EM", true)]
|
||||
[InlineData(AbLegacyDataType.ControlElement, "EN", false)]
|
||||
public void IsPlcSetStatusBit_classifies_writable_vs_status_bits(
|
||||
AbLegacyDataType dataType, string subElement, bool expected)
|
||||
{
|
||||
AbLegacyDataTypeExtensions.IsPlcSetStatusBit(dataType, subElement).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,4 +256,113 @@ public sealed class AbLegacyReadWriteTests
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Timer / Counter / Control sub-element bit semantics (issue #246) ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("T4:0.DN", 13)]
|
||||
[InlineData("T4:0.TT", 14)]
|
||||
[InlineData("T4:0.EN", 15)]
|
||||
public async Task Timer_status_bit_decodes_correct_position(string address, int bitPos)
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.TimerElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
// Seed a parent-word with only the target bit set.
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().Value.ShouldBe(true);
|
||||
// The driver must have asked the runtime for the right bit position.
|
||||
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Timer_PRE_subelement_decodes_as_int_word()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Pre", "ab://10.0.0.5/1,0", "T4:0.PRE", AbLegacyDataType.TimerElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 5000 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Pre"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().Value.ShouldBe(5000);
|
||||
factory.Tags["T4:0.PRE"].LastDecodeBitIndex.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("C5:0.UN", 10)]
|
||||
[InlineData("C5:0.OV", 11)]
|
||||
[InlineData("C5:0.DN", 12)]
|
||||
[InlineData("C5:0.CD", 13)]
|
||||
[InlineData("C5:0.CU", 14)]
|
||||
public async Task Counter_status_bit_decodes_correct_position(string address, int bitPos)
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.CounterElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(true);
|
||||
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("R6:0.FD", 8)]
|
||||
[InlineData("R6:0.IN", 9)]
|
||||
[InlineData("R6:0.UL", 10)]
|
||||
[InlineData("R6:0.ER", 11)]
|
||||
[InlineData("R6:0.EM", 12)]
|
||||
[InlineData("R6:0.DN", 13)]
|
||||
[InlineData("R6:0.EU", 14)]
|
||||
[InlineData("R6:0.EN", 15)]
|
||||
public async Task Control_status_bit_decodes_correct_position(string address, int bitPos)
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, AbLegacyDataType.ControlElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << bitPos };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(true);
|
||||
factory.Tags[address].LastDecodeBitIndex.ShouldBe(bitPos);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Status_bit_returns_false_when_parent_word_bit_is_clear()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new AbLegacyTagDefinition("Done", "ab://10.0.0.5/1,0", "T4:0.DN", AbLegacyDataType.TimerElement));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
// Bit 14 (TT) set, bit 13 (DN) clear.
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = 1 << 14 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Done"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(false);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("T4:0.DN", AbLegacyDataType.TimerElement)]
|
||||
[InlineData("T4:0.TT", AbLegacyDataType.TimerElement)]
|
||||
[InlineData("C5:0.DN", AbLegacyDataType.CounterElement)]
|
||||
[InlineData("C5:0.OV", AbLegacyDataType.CounterElement)]
|
||||
[InlineData("C5:0.UN", AbLegacyDataType.CounterElement)]
|
||||
[InlineData("R6:0.ER", AbLegacyDataType.ControlElement)]
|
||||
[InlineData("R6:0.EM", AbLegacyDataType.ControlElement)]
|
||||
[InlineData("R6:0.DN", AbLegacyDataType.ControlElement)]
|
||||
[InlineData("R6:0.FD", AbLegacyDataType.ControlElement)]
|
||||
public async Task Writes_to_PLC_set_status_bits_return_BadNotWritable(
|
||||
string address, AbLegacyDataType dataType)
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new AbLegacyTagDefinition("X", "ab://10.0.0.5/1,0", address, dataType));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("X", true)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotWritable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #249 — verify ST string read/write round-trips through the driver. The wire format
|
||||
/// (1-word length prefix + 82 ASCII bytes) is owned by libplctag's <c>GetString</c>/
|
||||
/// <c>SetString</c>; this test fixture pins the driver-level guarantees:
|
||||
/// <list type="bullet">
|
||||
/// <item>Reads round-trip strings of any length up to the 82-char ST cap.</item>
|
||||
/// <item>Writes longer than 82 chars are rejected with <c>BadOutOfRange</c> at the driver
|
||||
/// level — preventing libplctag from silently truncating.</item>
|
||||
/// <item>Embedded nulls and non-ASCII characters flow through without throwing — the latter
|
||||
/// is libplctag's responsibility to round-trip or degrade.</item>
|
||||
/// <item>Both Slc500 and Plc5 families share the 82-byte ST file convention.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyStringEncodingTests
|
||||
{
|
||||
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(
|
||||
AbLegacyPlcFamily family,
|
||||
params AbLegacyTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory();
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)],
|
||||
Tags = tags,
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- Read round-trip ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "")]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "Hello")]
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, "Hello")]
|
||||
public async Task Read_returns_string_value_unchanged(AbLegacyPlcFamily family, string value)
|
||||
{
|
||||
var (drv, factory) = NewDriver(family,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = value };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_returns_full_82_char_string_at_ST_capacity()
|
||||
{
|
||||
var full = new string('A', 82);
|
||||
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = full };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var actual = snapshots.Single().Value.ShouldBeOfType<string>();
|
||||
actual.Length.ShouldBe(82);
|
||||
actual.ShouldBe(full);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_preserves_embedded_null_byte()
|
||||
{
|
||||
// libplctag returns the C-string as the .NET String with whatever bytes the PLC stored.
|
||||
// We assert the driver doesn't strip or truncate at an embedded NUL.
|
||||
var withNull = "AB\0CD";
|
||||
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = withNull };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(withNull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_preserves_extended_latin_payload()
|
||||
{
|
||||
// PLC ST files are byte-oriented; non-ASCII passes through whatever round-trip libplctag
|
||||
// applies. The driver itself must not transform.
|
||||
var latin = "café résumé";
|
||||
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = p => new FakeAbLegacyTag(p) { Value = latin };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(latin);
|
||||
}
|
||||
|
||||
// ---- Write round-trip ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "")]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "Short msg")]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, "AB\0CD")] // embedded NUL
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, "Hello PLC5")]
|
||||
public async Task Write_succeeds_and_forwards_string_to_runtime(AbLegacyPlcFamily family, string value)
|
||||
{
|
||||
var (drv, factory) = NewDriver(family,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Msg", value)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags["ST9:0"].Value.ShouldBe(value);
|
||||
factory.Tags["ST9:0"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_succeeds_for_41_char_mid_length_string()
|
||||
{
|
||||
var mid = new string('M', 41);
|
||||
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Msg", mid)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags["ST9:0"].Value.ShouldBe(mid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_succeeds_at_82_char_boundary()
|
||||
{
|
||||
var full = new string('Z', 82);
|
||||
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Msg", full)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
((string)factory.Tags["ST9:0"].Value!).Length.ShouldBe(82);
|
||||
}
|
||||
|
||||
// ---- Length guard ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, 83)]
|
||||
[InlineData(AbLegacyPlcFamily.Slc500, 100)]
|
||||
[InlineData(AbLegacyPlcFamily.Plc5, 200)]
|
||||
public async Task Write_over_82_chars_returns_BadOutOfRange(AbLegacyPlcFamily family, int len)
|
||||
{
|
||||
// The runtime layer (LibplctagLegacyTagRuntime.EncodeValue) rejects with
|
||||
// ArgumentOutOfRangeException; the driver maps that to BadOutOfRange so the OPC UA client
|
||||
// gets a clean failure rather than a silent libplctag truncation. We use the production
|
||||
// runtime for the encode step but stub the I/O via a delegating factory so the test does
|
||||
// not need a real PLC.
|
||||
var oversized = new string('X', len);
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
// Reuse the production EncodeValue by routing through a fake that delegates the
|
||||
// length check itself — we model the runtime contract: > 82 chars must throw.
|
||||
Customise = p => new EncodeOnlyLengthCheckingFake(p),
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Msg", oversized)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
// The write must NOT have reached libplctag's WriteAsync — guard fires before flush.
|
||||
factory.Tags["ST9:0"].WriteCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_at_exactly_82_chars_does_not_trip_length_guard()
|
||||
{
|
||||
var atBoundary = new string('B', 82);
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new EncodeOnlyLengthCheckingFake(p),
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)],
|
||||
Tags =
|
||||
[
|
||||
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String),
|
||||
],
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Msg", atBoundary)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
factory.Tags["ST9:0"].WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test fake that mirrors <see cref="LibplctagLegacyTagRuntime"/>'s ST length guard so we
|
||||
/// can assert the driver-level mapping (ArgumentOutOfRangeException → BadOutOfRange)
|
||||
/// without instantiating a real libplctag <c>Tag</c> (which would try to open a TCP
|
||||
/// connection in <c>InitializeAsync</c>).
|
||||
/// </summary>
|
||||
private sealed class EncodeOnlyLengthCheckingFake(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
|
||||
{
|
||||
if (type == AbLegacyDataType.String)
|
||||
{
|
||||
var s = Convert.ToString(value) ?? string.Empty;
|
||||
if (s.Length > 82)
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(value),
|
||||
$"ST string write exceeds 82-byte file element capacity (was {s.Length}).");
|
||||
}
|
||||
base.EncodeValue(type, bitIndex, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,25 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
}
|
||||
|
||||
public virtual int GetStatus() => Status;
|
||||
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex) => Value;
|
||||
public int? LastDecodeBitIndex { get; private set; }
|
||||
public AbLegacyDataType? LastDecodeType { get; private set; }
|
||||
public virtual object? DecodeValue(AbLegacyDataType type, int? bitIndex)
|
||||
{
|
||||
LastDecodeType = type;
|
||||
LastDecodeBitIndex = bitIndex;
|
||||
// If the test seeded a parent-word value (ushort/short/int) and the driver asked for a
|
||||
// specific status bit, mask it out so we can assert the correct bit reaches the client.
|
||||
if (bitIndex is int bit && Value is not null and not bool)
|
||||
{
|
||||
try
|
||||
{
|
||||
var word = Convert.ToInt32(Value);
|
||||
return ((word >> bit) & 1) != 0;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException) { }
|
||||
}
|
||||
return Value;
|
||||
}
|
||||
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
@@ -46,8 +46,67 @@ internal class FakeFocasClient : IFocasClient
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public List<(int number, int axis, FocasDataType type)> DiagnosticReads { get; } = new();
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadDiagnosticAsync(
|
||||
int diagNumber, int axisOrZero, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||
DiagnosticReads.Add((diagNumber, axisOrZero, type));
|
||||
var key = axisOrZero == 0 ? $"DIAG:{diagNumber}" : $"DIAG:{diagNumber}/{axisOrZero}";
|
||||
var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good;
|
||||
var value = Values.TryGetValue(key, out var v) ? v : null;
|
||||
return Task.FromResult((value, status));
|
||||
}
|
||||
|
||||
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
||||
|
||||
/// <summary>
|
||||
/// Configurable path count surfaced via <see cref="GetPathCountAsync"/> — defaults to
|
||||
/// 1 (single-path controller). Tests asserting multi-path behaviour set this to 2..N
|
||||
/// so the driver's PathId validation + cnc_setpath dispatch can be exercised
|
||||
/// without a live CNC (issue #264).
|
||||
/// </summary>
|
||||
public int PathCount { get; set; } = 1;
|
||||
|
||||
/// <summary>Ordered log of <c>cnc_setpath</c> calls observed on this fake session.</summary>
|
||||
public List<int> SetPathLog { get; } = new();
|
||||
|
||||
public virtual Task<int> GetPathCountAsync(CancellationToken ct) => Task.FromResult(PathCount);
|
||||
|
||||
public virtual Task SetPathAsync(int pathId, CancellationToken ct)
|
||||
{
|
||||
SetPathLog.Add(pathId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-letter / per-path byte storage the coalesced range path reads from. Tests
|
||||
/// populate <c>PmcByteRanges[("R", 1)] = new byte[size]</c> + the corresponding values to
|
||||
/// drive both the per-tag <see cref="ReadAsync"/> + the coalesced
|
||||
/// <see cref="ReadPmcRangeAsync"/> path against the same source of truth (issue #266).
|
||||
/// </summary>
|
||||
public Dictionary<(string Letter, int PathId), byte[]> PmcByteRanges { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Ordered log of <c>pmc_rdpmcrng</c>-shaped range calls observed on this fake
|
||||
/// session — one entry per coalesced wire call. Tests assert this count to verify
|
||||
/// coalescing actually collapsed N per-byte reads into one range read (issue #266).
|
||||
/// </summary>
|
||||
public List<(string Letter, int PathId, int StartByte, int ByteCount)> RangeReadLog { get; } = new();
|
||||
|
||||
public virtual Task<(byte[]? buffer, uint status)> ReadPmcRangeAsync(
|
||||
string letter, int pathId, int startByte, int byteCount, CancellationToken ct)
|
||||
{
|
||||
RangeReadLog.Add((letter, pathId, startByte, byteCount));
|
||||
if (!PmcByteRanges.TryGetValue((letter.ToUpperInvariant(), pathId), out var src))
|
||||
return Task.FromResult<(byte[]?, uint)>((new byte[byteCount], FocasStatusMapper.Good));
|
||||
var buf = new byte[byteCount];
|
||||
var copy = Math.Min(byteCount, Math.Max(0, src.Length - startByte));
|
||||
if (copy > 0) Array.Copy(src, startByte, buf, 0, copy);
|
||||
return Task.FromResult<(byte[]?, uint)>((buf, FocasStatusMapper.Good));
|
||||
}
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
|
||||
@@ -57,8 +57,9 @@ public sealed class FocasCapabilityMatrixTests
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "X", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "R", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "F", false)] // 16i has no F/G signal groups
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "G", false)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "F", true)] // #265: F/G handshakes are documented on 16i ladders
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "G", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "M", false)] // M/C/K/T still 0i-F / 30i-only
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "K", false)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D
|
||||
[InlineData(FocasCncSeries.Zero_i_D, "F", false)] // still no F on 0i-D
|
||||
|
||||
@@ -31,8 +31,10 @@ public sealed class FocasCapabilityTests
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "FOCAS");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1");
|
||||
builder.Variables.Single(v => v.BrowseName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
// Per-tag and Status/ fields can share a BrowseName ("Run", "Alarm") under different
|
||||
// parent folders — disambiguate by FullName, which is unique per node.
|
||||
builder.Variables.Single(v => v.Info.FullName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.Info.FullName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for the <c>DIAG:</c> address scheme — parser, capability matrix,
|
||||
/// driver dispatch (issue #263, plan PR F2-a). DIAG: addresses route to
|
||||
/// <c>cnc_rddiag</c> on the wire; the driver validates against
|
||||
/// <see cref="FocasCapabilityMatrix.DiagnosticRange"/> at init time + dispatches
|
||||
/// <see cref="FocasAreaKind.Diagnostic"/> reads through
|
||||
/// <see cref="IFocasClient.ReadDiagnosticAsync"/> at runtime.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasDiagnosticAddressTests
|
||||
{
|
||||
// ---- Parser positive ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("DIAG:1000", 1000, 0)]
|
||||
[InlineData("DIAG:280/2", 280, 2)]
|
||||
[InlineData("DIAG:0", 0, 0)]
|
||||
[InlineData("diag:500", 500, 0)] // case-insensitive prefix
|
||||
[InlineData("DIAG:1023/8", 1023, 8)]
|
||||
public void TryParse_accepts_DIAG_forms(string input, int expectedNumber, int expectedAxis)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Diagnostic);
|
||||
parsed.Number.ShouldBe(expectedNumber);
|
||||
(parsed.BitIndex ?? 0).ShouldBe(expectedAxis);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DIAG:abc")]
|
||||
[InlineData("DIAG:")]
|
||||
[InlineData("DIAG:-1")]
|
||||
[InlineData("DIAG:100/-1")]
|
||||
[InlineData("DIAG:100/99")] // axis > 31 (parser ceiling)
|
||||
public void TryParse_rejects_malformed_DIAG(string input)
|
||||
{
|
||||
FocasAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Canonical_round_trip_for_DIAG_whole_CNC()
|
||||
{
|
||||
var parsed = FocasAddress.TryParse("DIAG:1000");
|
||||
parsed!.Canonical.ShouldBe("DIAG:1000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Canonical_round_trip_for_DIAG_per_axis()
|
||||
{
|
||||
var parsed = FocasAddress.TryParse("DIAG:280/2");
|
||||
parsed!.Canonical.ShouldBe("DIAG:280/2");
|
||||
}
|
||||
|
||||
// ---- Capability matrix ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasCncSeries.Thirty_i, 1023, true)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, 1024, false)]
|
||||
[InlineData(FocasCncSeries.ThirtyOne_i, 500, true)]
|
||||
[InlineData(FocasCncSeries.ThirtyTwo_i, 0, true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, 499, true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, 500, false)] // 16i caps lower
|
||||
[InlineData(FocasCncSeries.Zero_i_F, 999, true)]
|
||||
[InlineData(FocasCncSeries.Zero_i_F, 1000, false)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, 280, true)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, 600, false)]
|
||||
[InlineData(FocasCncSeries.PowerMotion_i, 255, true)]
|
||||
[InlineData(FocasCncSeries.PowerMotion_i, 256, false)]
|
||||
public void Diagnostic_range_matches_series(FocasCncSeries series, int number, bool accepted)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Diagnostic, null, number, null);
|
||||
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||
(result is null).ShouldBe(accepted,
|
||||
$"DIAG:{number} on {series}: expected {(accepted ? "accept" : "reject")}, got '{result}'");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_series_accepts_any_diagnostic_number()
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Diagnostic, null, 99_999, null);
|
||||
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Diagnostic_rejection_message_names_series_and_limit()
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Diagnostic, null, 5_000, null);
|
||||
var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Sixteen_i, address);
|
||||
reason.ShouldNotBeNull();
|
||||
reason.ShouldContain("5000");
|
||||
reason.ShouldContain("Sixteen_i");
|
||||
reason.ShouldContain("499");
|
||||
}
|
||||
|
||||
// ---- Driver dispatch ----
|
||||
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(
|
||||
FocasCncSeries series,
|
||||
params FocasTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", Series: series)],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DIAG_read_routes_through_ReadDiagnosticAsync_with_axis_zero()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
FocasCncSeries.Thirty_i,
|
||||
new FocasTagDefinition("AlarmCause", "focas://10.0.0.5:8193", "DIAG:1000", FocasDataType.Int32));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.Values["DIAG:1000"] = 42;
|
||||
return c;
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["AlarmCause"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(42);
|
||||
var fake = factory.Clients.Single();
|
||||
fake.DiagnosticReads.Single().ShouldBe((1000, 0, FocasDataType.Int32));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DIAG_per_axis_read_threads_axis_index_through()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
FocasCncSeries.Thirty_i,
|
||||
new FocasTagDefinition("ServoLoad2", "focas://10.0.0.5:8193", "DIAG:280/2", FocasDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.Values["DIAG:280/2"] = (short)17;
|
||||
return c;
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["ServoLoad2"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe((short)17);
|
||||
factory.Clients.Single().DiagnosticReads.Single().ShouldBe((280, 2, FocasDataType.Int16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DIAG_out_of_range_for_series_rejected_at_init()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
FocasCncSeries.Sixteen_i,
|
||||
new FocasTagDefinition("Bad", "focas://10.0.0.5:8193", "DIAG:5000", FocasDataType.Int32));
|
||||
var ex = await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
ex.Message.ShouldContain("5000");
|
||||
ex.Message.ShouldContain("Sixteen_i");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DIAG_default_interface_method_surfaces_BadNotSupported()
|
||||
{
|
||||
// Stand-in client that does NOT override ReadDiagnosticAsync — falls through to
|
||||
// the IFocasClient default returning BadNotSupported. Models a transport variant
|
||||
// (e.g. older IPC contract) that hasn't extended its wire surface to diagnostics.
|
||||
var factory = new BareFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", Series: FocasCncSeries.Unknown)],
|
||||
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "DIAG:100", FocasDataType.Int32)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test stand-in that overrides every interface method we need EXCEPT
|
||||
/// <see cref="IFocasClient.ReadDiagnosticAsync"/> — exercising the default
|
||||
/// implementation that returns <c>BadNotSupported</c> for transports that
|
||||
/// haven't extended their wire surface yet.
|
||||
/// </summary>
|
||||
private sealed class FakeWithoutDiagnosticOverride : IFocasClient
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
public Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
|
||||
{ IsConnected = true; return Task.CompletedTask; }
|
||||
public Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct) =>
|
||||
Task.FromResult<(object?, uint)>((null, FocasStatusMapper.Good));
|
||||
public Task<uint> WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken ct) =>
|
||||
Task.FromResult(FocasStatusMapper.Good);
|
||||
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class BareFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public IFocasClient Create() => new FakeWithoutDiagnosticOverride();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasFigureScalingDiagnosticsTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.7:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
||||
/// per-axis figure scaling for the F1-f cache + diagnostics surface
|
||||
/// (issue #262).
|
||||
/// </summary>
|
||||
private sealed class FigureAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public IReadOnlyDictionary<string, int>? Scaling { get; set; }
|
||||
|
||||
Task<IReadOnlyDictionary<string, int>?> IFocasClient.GetFigureScalingAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Scaling);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Diagnostics_subtree_with_five_counters()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-diag", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Diagnostics" && f.DisplayName == "Diagnostics");
|
||||
var diagVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Diagnostics/")).ToList();
|
||||
diagVars.Count.ShouldBe(5);
|
||||
|
||||
// Verify per-field types match the documented surface (Int64 counters,
|
||||
// String error message, DateTime last-success timestamp).
|
||||
diagVars.Single(v => v.BrowseName == "ReadCount")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
|
||||
diagVars.Single(v => v.BrowseName == "ReadFailureCount")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
|
||||
diagVars.Single(v => v.BrowseName == "ReconnectCount")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.Int64);
|
||||
diagVars.Single(v => v.BrowseName == "LastErrorMessage")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
diagVars.Single(v => v.BrowseName == "LastSuccessfulRead")
|
||||
.Info.DriverDataType.ShouldBe(DriverDataType.DateTime);
|
||||
|
||||
foreach (var v in diagVars)
|
||||
v.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_publishes_diagnostics_counters_after_probe_ticks()
|
||||
{
|
||||
// Probe enabled — successful ticks bump ReadCount + LastSuccessfulRead;
|
||||
// ReconnectCount bumps once on the initial connect (issue #262).
|
||||
var fake = new FakeFocasClient { ProbeResult = true };
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
|
||||
}, "drv-diag-read", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least 2 successful probe ticks so ReadCount > 0 deterministically.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
|
||||
return snap.Value is long n && n >= 2;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Diagnostics/ReadCount",
|
||||
$"{Host}::Diagnostics/ReadFailureCount",
|
||||
$"{Host}::Diagnostics/ReconnectCount",
|
||||
$"{Host}::Diagnostics/LastErrorMessage",
|
||||
$"{Host}::Diagnostics/LastSuccessfulRead",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
((long)snaps[0].Value!).ShouldBeGreaterThanOrEqualTo(2);
|
||||
((long)snaps[1].Value!).ShouldBe(0); // no failures on a healthy probe
|
||||
((long)snaps[2].Value!).ShouldBe(1); // one initial connect
|
||||
snaps[3].Value.ShouldBe(string.Empty);
|
||||
((DateTime)snaps[4].Value!).ShouldBeGreaterThan(DateTime.MinValue);
|
||||
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_increments_ReadFailureCount_when_probe_returns_false()
|
||||
{
|
||||
// ProbeResult=false → success branch is skipped, ReadFailureCount bumps each
|
||||
// tick. The connect itself succeeded so ReconnectCount is 1.
|
||||
var fake = new FakeFocasClient { ProbeResult = false };
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
|
||||
}, "drv-diag-fail", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadFailureCount"], CancellationToken.None)).Single();
|
||||
return snap.Value is long n && n >= 2;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadCount", $"{Host}::Diagnostics/ReadFailureCount"],
|
||||
CancellationToken.None);
|
||||
((long)snaps[0].Value!).ShouldBe(0);
|
||||
((long)snaps[1].Value!).ShouldBeGreaterThanOrEqualTo(2);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyFigureScaling_divides_raw_position_by_ten_to_the_decimal_places()
|
||||
{
|
||||
// Cache populated via probe-tick GetFigureScalingAsync. ApplyFigureScaling
|
||||
// default is true → rawValue / 10^dec for the named axis (issue #262).
|
||||
var fake = new FigureAwareFakeFocasClient
|
||||
{
|
||||
Scaling = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["axis1"] = 3, // X-axis: 3 decimal places (mm * 1000)
|
||||
["axis2"] = 4, // Y-axis: 4 decimal places
|
||||
},
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
|
||||
}, "drv-fig", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for the probe-tick path to populate the cache (one successful tick is
|
||||
// enough — the figure-scaling read happens whenever the cache is null).
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
|
||||
return snap.Value is long n && n >= 1;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
// 100000 / 10^3 = 100.0 mm
|
||||
drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100.0);
|
||||
// 250000 / 10^4 = 25.0 mm
|
||||
drv.ApplyFigureScaling(Host, "axis2", 250000).ShouldBe(25.0);
|
||||
// Unknown axis → raw value passes through.
|
||||
drv.ApplyFigureScaling(Host, "axis3", 42).ShouldBe(42.0);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyFigureScaling_returns_raw_when_FixedTreeApplyFigureScaling_is_false()
|
||||
{
|
||||
// ApplyFigureScaling=false short-circuits before the cache lookup so the raw
|
||||
// integer is published unchanged. Migration parity for deployments that already
|
||||
// surfaced raw values from older drivers (issue #262).
|
||||
var fake = new FigureAwareFakeFocasClient
|
||||
{
|
||||
Scaling = new Dictionary<string, int> { ["axis1"] = 3 },
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30) },
|
||||
FixedTree = new FocasFixedTreeOptions { ApplyFigureScaling = false },
|
||||
}, "drv-fig-off", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Diagnostics/ReadCount"], CancellationToken.None)).Single();
|
||||
return snap.Value is long n && n >= 1;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
// Even though the cache has axis1 → 3 decimal places, ApplyFigureScaling=false
|
||||
// means the raw value passes through unchanged.
|
||||
drv.ApplyFigureScaling(Host, "axis1", 100000).ShouldBe(100000.0);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetFigureScaling_returns_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); the unconnected client must
|
||||
// short-circuit before P/Invoke so the driver leaves the cache untouched.
|
||||
var client = new FwlibFocasClient();
|
||||
(await client.GetFigureScalingAsync(CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeFigureScaling_extracts_per_axis_decimal_places_from_buffer()
|
||||
{
|
||||
// Build an IODBAXIS-shaped buffer: 3 axes, decimal places = 3, 4, 0. Per
|
||||
// fwlib32.h each axis entry is { short dec, short unit, short reserved,
|
||||
// short reserved2 } = 8 bytes; we only read dec.
|
||||
var buf = new byte[FwlibNative.MAX_AXIS * 8];
|
||||
// Axis 1: dec=3
|
||||
buf[0] = 3; buf[1] = 0;
|
||||
// Axis 2: dec=4
|
||||
buf[8] = 4; buf[9] = 0;
|
||||
// Axis 3: dec=0 (already zero)
|
||||
|
||||
var map = FwlibFocasClient.DecodeFigureScaling(buf, count: 3);
|
||||
map.Count.ShouldBe(3);
|
||||
map["axis1"].ShouldBe(3);
|
||||
map["axis2"].ShouldBe(4);
|
||||
map["axis3"].ShouldBe(0);
|
||||
|
||||
// Out-of-range count clamps to MAX_AXIS so a malformed CNC reply doesn't
|
||||
// overrun the buffer.
|
||||
var clamped = FwlibFocasClient.DecodeFigureScaling(buf, count: 99);
|
||||
clamped.Count.ShouldBe(FwlibNative.MAX_AXIS);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasMessagesBlockTextFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.7:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
||||
/// <see cref="FocasOperatorMessagesInfo"/> + <see cref="FocasCurrentBlockInfo"/>
|
||||
/// snapshots for the F1-e Messages/External/Latest + Program/CurrentBlock
|
||||
/// fixed-tree (issue #261).
|
||||
/// </summary>
|
||||
private sealed class MessagesAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasOperatorMessagesInfo? Messages { get; set; }
|
||||
public FocasCurrentBlockInfo? CurrentBlock { get; set; }
|
||||
|
||||
Task<FocasOperatorMessagesInfo?> IFocasClient.GetOperatorMessagesAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Messages);
|
||||
|
||||
Task<FocasCurrentBlockInfo?> IFocasClient.GetCurrentBlockAsync(CancellationToken ct) =>
|
||||
Task.FromResult(CurrentBlock);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Messages_External_Latest_and_Program_CurrentBlock_nodes()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-msg", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Messages" && f.DisplayName == "Messages");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "External" && f.DisplayName == "External");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Program" && f.DisplayName == "Program");
|
||||
|
||||
var latest = builder.Variables.SingleOrDefault(v =>
|
||||
v.Info.FullName == $"{Host}::Messages/External/Latest");
|
||||
latest.BrowseName.ShouldBe("Latest");
|
||||
latest.Info.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
latest.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
|
||||
var block = builder.Variables.SingleOrDefault(v =>
|
||||
v.Info.FullName == $"{Host}::Program/CurrentBlock");
|
||||
block.BrowseName.ShouldBe("CurrentBlock");
|
||||
block.Info.DriverDataType.ShouldBe(DriverDataType.String);
|
||||
block.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_Messages_Latest_and_CurrentBlock_from_cached_snapshot()
|
||||
{
|
||||
var fake = new MessagesAwareFakeFocasClient
|
||||
{
|
||||
Messages = new FocasOperatorMessagesInfo(
|
||||
[
|
||||
new FocasOperatorMessage(2001, "OPMSG", "TOOL CHANGE READY"),
|
||||
new FocasOperatorMessage(3010, "EXTERN", "DOOR OPEN"),
|
||||
]),
|
||||
CurrentBlock = new FocasCurrentBlockInfo("G01 X100. Y200. F500."),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-msg-read", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Program/CurrentBlock"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Messages/External/Latest",
|
||||
$"{Host}::Program/CurrentBlock",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
// "Latest" surfaces the last entry in the message snapshot — issue #261 permits
|
||||
// this minimal "latest message" surface in lieu of full ring-buffer coverage.
|
||||
snaps[0].Value.ShouldBe("DOOR OPEN");
|
||||
snaps[1].Value.ShouldBe("G01 X100. Y200. F500.");
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
|
||||
{
|
||||
// Probe disabled — neither cache populates; the nodes still resolve as known
|
||||
// references but report Bad until the first poll. Mirrors the f1a/f1b/f1c/f1d
|
||||
// policy.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-msg-empty", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"],
|
||||
CancellationToken.None);
|
||||
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_publishes_empty_string_when_message_snapshot_is_empty()
|
||||
{
|
||||
// Empty snapshot (CNC reported no active messages) still publishes Good +
|
||||
// empty string — operators distinguish "no messages" from "Bad" without
|
||||
// having to read separate availability nodes.
|
||||
var fake = new MessagesAwareFakeFocasClient
|
||||
{
|
||||
Messages = new FocasOperatorMessagesInfo([]),
|
||||
CurrentBlock = new FocasCurrentBlockInfo(""),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-msg-empty-snap", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Messages/External/Latest"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"],
|
||||
CancellationToken.None);
|
||||
snaps[0].Value.ShouldBe(string.Empty);
|
||||
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snaps[1].Value.ShouldBe(string.Empty);
|
||||
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetOperatorMessages_and_GetCurrentBlock_return_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); the unconnected client must
|
||||
// short-circuit before P/Invoke. Returns null → driver leaves the cache
|
||||
// untouched, matching the policy in f1a/f1b/f1c/f1d.
|
||||
var client = new FwlibFocasClient();
|
||||
(await client.GetOperatorMessagesAsync(CancellationToken.None)).ShouldBeNull();
|
||||
(await client.GetCurrentBlockAsync(CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrimAnsiPadding_strips_trailing_nulls_and_spaces_for_round_trip()
|
||||
{
|
||||
// The CNC right-pads block text + opmsg bodies with NULs or spaces; the
|
||||
// managed side trims them so the same message round-trips with stable text
|
||||
// (issue #261). Stops at the first NUL so reused buffers don't leak old bytes.
|
||||
var buf = new byte[16];
|
||||
var bytes = System.Text.Encoding.ASCII.GetBytes("G01 X10 ");
|
||||
Array.Copy(bytes, buf, bytes.Length);
|
||||
FwlibFocasClient.TrimAnsiPadding(buf).ShouldBe("G01 X10");
|
||||
|
||||
// NUL-terminated mid-buffer with trailing spaces beyond the NUL — trim stops
|
||||
// at the NUL so leftover bytes in the rest of the buffer are ignored.
|
||||
var buf2 = new byte[32];
|
||||
var bytes2 = System.Text.Encoding.ASCII.GetBytes("OPMSG TEXT");
|
||||
Array.Copy(bytes2, buf2, bytes2.Length);
|
||||
// After NUL the buffer has zeros — already invisible — but explicit space
|
||||
// padding before the NUL should be trimmed.
|
||||
var buf3 = new byte[32];
|
||||
var bytes3 = System.Text.Encoding.ASCII.GetBytes("HELLO ");
|
||||
Array.Copy(bytes3, buf3, bytes3.Length);
|
||||
FwlibFocasClient.TrimAnsiPadding(buf2).ShouldBe("OPMSG TEXT");
|
||||
FwlibFocasClient.TrimAnsiPadding(buf3).ShouldBe("HELLO");
|
||||
|
||||
// Empty buffer → empty string (no exception).
|
||||
FwlibFocasClient.TrimAnsiPadding(new byte[8]).ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasModalOverrideFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.6:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
||||
/// <see cref="FocasModalInfo"/> + <see cref="FocasOverrideInfo"/> snapshots.
|
||||
/// </summary>
|
||||
private sealed class ModalAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasModalInfo? Modal { get; set; }
|
||||
public FocasOverrideInfo? Override { get; set; }
|
||||
public FocasOverrideParameters? LastOverrideParams { get; private set; }
|
||||
|
||||
Task<FocasModalInfo?> IFocasClient.GetModalAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Modal);
|
||||
|
||||
Task<FocasOverrideInfo?> IFocasClient.GetOverrideAsync(
|
||||
FocasOverrideParameters parameters, CancellationToken ct)
|
||||
{
|
||||
LastOverrideParams = parameters;
|
||||
return Task.FromResult(Override);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Modal_folder_with_4_Int16_codes_per_device()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-2")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-modal", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Modal" && f.DisplayName == "Modal");
|
||||
var modalVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Modal/")).ToList();
|
||||
modalVars.Count.ShouldBe(4);
|
||||
string[] expected = ["MCode", "SCode", "TCode", "BCode"];
|
||||
foreach (var name in expected)
|
||||
{
|
||||
var node = modalVars.SingleOrDefault(v => v.BrowseName == name);
|
||||
node.BrowseName.ShouldBe(name);
|
||||
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
node.Info.FullName.ShouldBe($"{Host}::Modal/{name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_omits_Override_folder_when_no_parameters_configured()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)], // OverrideParameters defaults to null
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-no-overrides", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldNotContain(f => f.BrowseName == "Override");
|
||||
builder.Variables.ShouldNotContain(v => v.Info.FullName.Contains("::Override/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_only_configured_Override_fields()
|
||||
{
|
||||
// Spindle + Jog suppressed (null parameters) — only Feed + Rapid show up.
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions(Host,
|
||||
OverrideParameters: new FocasOverrideParameters(6010, 6011, null, null)),
|
||||
],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-partial-overrides", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Override");
|
||||
var overrideVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Override/")).ToList();
|
||||
overrideVars.Count.ShouldBe(2);
|
||||
overrideVars.ShouldContain(v => v.BrowseName == "Feed");
|
||||
overrideVars.ShouldContain(v => v.BrowseName == "Rapid");
|
||||
overrideVars.ShouldNotContain(v => v.BrowseName == "Spindle");
|
||||
overrideVars.ShouldNotContain(v => v.BrowseName == "Jog");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_Modal_and_Override_fields_from_cached_snapshot()
|
||||
{
|
||||
var fake = new ModalAwareFakeFocasClient
|
||||
{
|
||||
Modal = new FocasModalInfo(MCode: 8, SCode: 1200, TCode: 101, BCode: 0),
|
||||
Override = new FocasOverrideInfo(Feed: 100, Rapid: 50, Spindle: 110, Jog: 25),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions(Host,
|
||||
OverrideParameters: FocasOverrideParameters.Default),
|
||||
],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-modal-read", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least one probe tick to populate both caches.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Modal/MCode"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Modal/MCode",
|
||||
$"{Host}::Modal/SCode",
|
||||
$"{Host}::Modal/TCode",
|
||||
$"{Host}::Modal/BCode",
|
||||
$"{Host}::Override/Feed",
|
||||
$"{Host}::Override/Rapid",
|
||||
$"{Host}::Override/Spindle",
|
||||
$"{Host}::Override/Jog",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
snaps[0].Value.ShouldBe((short)8);
|
||||
snaps[1].Value.ShouldBe((short)1200);
|
||||
snaps[2].Value.ShouldBe((short)101);
|
||||
snaps[3].Value.ShouldBe((short)0);
|
||||
snaps[4].Value.ShouldBe((short)100);
|
||||
snaps[5].Value.ShouldBe((short)50);
|
||||
snaps[6].Value.ShouldBe((short)110);
|
||||
snaps[7].Value.ShouldBe((short)25);
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
// The driver hands the device's configured override parameters to the wire client
|
||||
// verbatim — defaulting to 30i numbers.
|
||||
fake.LastOverrideParams.ShouldNotBeNull();
|
||||
fake.LastOverrideParams!.FeedParam.ShouldBe<ushort?>(6010);
|
||||
fake.LastOverrideParams.RapidParam.ShouldBe<ushort?>(6011);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
|
||||
{
|
||||
// Probe disabled — neither modal nor override caches populate; the nodes still
|
||||
// resolve as known references but report Bad until the first successful poll.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions(Host,
|
||||
OverrideParameters: FocasOverrideParameters.Default),
|
||||
],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-empty-cache", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Modal/MCode", $"{Host}::Override/Feed"], CancellationToken.None);
|
||||
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetModal_and_GetOverride_return_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); the unconnected client must short-
|
||||
// circuit before P/Invoke. Returns null → driver leaves the cache untouched.
|
||||
var client = new FwlibFocasClient();
|
||||
(await client.GetModalAsync(CancellationToken.None)).ShouldBeNull();
|
||||
(await client.GetOverrideAsync(
|
||||
FocasOverrideParameters.Default, CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Coverage for multi-path / multi-channel CNC support — parser, driver bootstrap,
|
||||
/// <c>cnc_setpath</c> dispatch (issue #264, plan PR F2-b). The <c>@N</c> suffix
|
||||
/// selects which path a given address is read from; default <c>PathId=1</c>
|
||||
/// preserves single-path back-compat.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasMultiPathTests
|
||||
{
|
||||
// ---- Parser positive ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("R100", "R", 100, null, 1)]
|
||||
[InlineData("R100@2", "R", 100, null, 2)]
|
||||
[InlineData("R100@3.0", "R", 100, 0, 3)]
|
||||
[InlineData("X0.7", "X", 0, 7, 1)]
|
||||
[InlineData("X0@2.7", "X", 0, 7, 2)]
|
||||
public void TryParse_PMC_supports_optional_path_suffix(
|
||||
string input, string letter, int number, int? bit, int expectedPath)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Pmc);
|
||||
parsed.PmcLetter.ShouldBe(letter);
|
||||
parsed.Number.ShouldBe(number);
|
||||
parsed.BitIndex.ShouldBe(bit);
|
||||
parsed.PathId.ShouldBe(expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PARAM:1815", 1815, null, 1)]
|
||||
[InlineData("PARAM:1815@2", 1815, null, 2)]
|
||||
[InlineData("PARAM:1815@2/0", 1815, 0, 2)]
|
||||
[InlineData("PARAM:1815/0", 1815, 0, 1)]
|
||||
public void TryParse_PARAM_supports_optional_path_suffix(
|
||||
string input, int number, int? bit, int expectedPath)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Parameter);
|
||||
parsed.Number.ShouldBe(number);
|
||||
parsed.BitIndex.ShouldBe(bit);
|
||||
parsed.PathId.ShouldBe(expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MACRO:500", 500, 1)]
|
||||
[InlineData("MACRO:500@2", 500, 2)]
|
||||
[InlineData("MACRO:500@10", 500, 10)]
|
||||
public void TryParse_MACRO_supports_optional_path_suffix(string input, int number, int expectedPath)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Macro);
|
||||
parsed.Number.ShouldBe(number);
|
||||
parsed.PathId.ShouldBe(expectedPath);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DIAG:280", 280, 0, 1)]
|
||||
[InlineData("DIAG:280@2", 280, 0, 2)]
|
||||
[InlineData("DIAG:280@2/1", 280, 1, 2)]
|
||||
[InlineData("DIAG:280/1", 280, 1, 1)]
|
||||
public void TryParse_DIAG_supports_optional_path_suffix(
|
||||
string input, int number, int axis, int expectedPath)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Kind.ShouldBe(FocasAreaKind.Diagnostic);
|
||||
parsed.Number.ShouldBe(number);
|
||||
(parsed.BitIndex ?? 0).ShouldBe(axis);
|
||||
parsed.PathId.ShouldBe(expectedPath);
|
||||
}
|
||||
|
||||
// ---- Parser negative ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("R100@0")] // path 0 — reserved (FOCAS path numbering is 1-based)
|
||||
[InlineData("R100@-1")] // negative path
|
||||
[InlineData("R100@11")] // above FWLIB ceiling
|
||||
[InlineData("R100@abc")] // non-numeric
|
||||
[InlineData("R100@")] // empty
|
||||
[InlineData("PARAM:1815@0")]
|
||||
[InlineData("PARAM:1815@99")]
|
||||
[InlineData("MACRO:500@0")]
|
||||
[InlineData("DIAG:280@0/1")]
|
||||
public void TryParse_rejects_invalid_path_suffix(string input)
|
||||
{
|
||||
FocasAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- Canonical round-trip ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("R100")]
|
||||
[InlineData("R100@2")]
|
||||
[InlineData("R100@3.0")]
|
||||
[InlineData("PARAM:1815")]
|
||||
[InlineData("PARAM:1815@2")]
|
||||
[InlineData("PARAM:1815@2/0")]
|
||||
[InlineData("MACRO:500")]
|
||||
[InlineData("MACRO:500@2")]
|
||||
[InlineData("DIAG:280")]
|
||||
[InlineData("DIAG:280@2")]
|
||||
[InlineData("DIAG:280@2/1")]
|
||||
public void Canonical_round_trips_through_parser(string input)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Canonical.ShouldBe(input);
|
||||
}
|
||||
|
||||
// ---- Driver dispatch ----
|
||||
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(
|
||||
params FocasTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Default_PathId_1_does_not_trigger_SetPath()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
PathCount = 2,
|
||||
Values = { ["R100"] = (sbyte)1 },
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
factory.Clients.Single().SetPathLog.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_default_PathId_calls_SetPath_before_read()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100@2", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
PathCount = 2,
|
||||
Values = { ["R100@2"] = (sbyte)7 },
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe((sbyte)7);
|
||||
factory.Clients.Single().SetPathLog.ShouldBe(new[] { 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_on_same_path_only_calls_SetPath_once()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100@2", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101@2", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
PathCount = 2,
|
||||
Values = { ["R100@2"] = (sbyte)1, ["R101@2"] = (sbyte)2 },
|
||||
};
|
||||
|
||||
await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
||||
// Two reads on the same non-default path — SetPath should only fire on the first.
|
||||
factory.Clients.Single().SetPathLog.ShouldBe(new[] { 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Switching_paths_in_one_read_batch_logs_each_change()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100@2", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R200@3", FocasDataType.Byte),
|
||||
new FocasTagDefinition("C", "focas://10.0.0.5:8193", "R300@2", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
PathCount = 3,
|
||||
Values =
|
||||
{
|
||||
["R100@2"] = (sbyte)1,
|
||||
["R200@3"] = (sbyte)2,
|
||||
["R300@2"] = (sbyte)3,
|
||||
},
|
||||
};
|
||||
|
||||
await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
// Path 2 → 3 → 2 — each transition fires SetPath.
|
||||
factory.Clients.Single().SetPathLog.ShouldBe(new[] { 2, 3, 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PathId_above_PathCount_returns_BadOutOfRange()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100@5", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { PathCount = 2 };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadOutOfRange);
|
||||
// Out-of-range tag must not pollute the wire with a setpath call.
|
||||
factory.Clients.Single().SetPathLog.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Diagnostic_path_threads_through_SetPath_then_ReadDiagnosticAsync()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("ServoLoad", "focas://10.0.0.5:8193", "DIAG:280@2/1", FocasDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
PathCount = 2,
|
||||
Values = { ["DIAG:280/1"] = (short)42 },
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["ServoLoad"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe((short)42);
|
||||
var fake = factory.Clients.Single();
|
||||
fake.SetPathLog.ShouldBe(new[] { 2 });
|
||||
fake.DiagnosticReads.Single().ShouldBe((280, 1, FocasDataType.Int16));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Single_path_controller_with_default_addresses_never_calls_SetPath()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "PARAM:1815", FocasDataType.Int32),
|
||||
new FocasTagDefinition("C", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
PathCount = 1,
|
||||
Values =
|
||||
{
|
||||
["R100"] = (sbyte)1,
|
||||
["PARAM:1815"] = 100,
|
||||
["MACRO:500"] = 1.5,
|
||||
},
|
||||
};
|
||||
|
||||
await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
factory.Clients.Single().SetPathLog.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasPmcCoalescedReadTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory, FakeFocasClient client) NewDriver(
|
||||
params FocasTagDefinition[] tags)
|
||||
{
|
||||
var client = new FakeFocasClient();
|
||||
var factory = new FakeFocasClientFactory { Customise = () => client };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-coalesce", factory);
|
||||
return (drv, factory, client);
|
||||
}
|
||||
|
||||
private static FocasTagDefinition Tag(string name, string addr, FocasDataType type) =>
|
||||
new(name, Host, addr, type);
|
||||
|
||||
[Fact]
|
||||
public async Task Hundred_contiguous_PMC_bytes_collapse_to_one_wire_call()
|
||||
{
|
||||
// 100 contiguous R-letter byte-shaped tags → coalescer cap is 256 → one range read.
|
||||
var tags = new FocasTagDefinition[100];
|
||||
var refs = new string[100];
|
||||
for (var i = 0; i < 100; i++)
|
||||
{
|
||||
tags[i] = Tag($"r{i}", $"R{i}", FocasDataType.Byte);
|
||||
refs[i] = $"r{i}";
|
||||
}
|
||||
var (drv, _, client) = NewDriver(tags);
|
||||
client.PmcByteRanges[("R", 1)] = new byte[200];
|
||||
for (var i = 0; i < 100; i++) client.PmcByteRanges[("R", 1)][i] = (byte)(i + 1);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(100);
|
||||
foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
// sbyte cast: 1 → 1, 100 stays positive
|
||||
snapshots[0].Value.ShouldBe((sbyte)1);
|
||||
snapshots[99].Value.ShouldBe((sbyte)100);
|
||||
client.RangeReadLog.Count.ShouldBe(1);
|
||||
client.RangeReadLog[0].Letter.ShouldBe("R");
|
||||
client.RangeReadLog[0].StartByte.ShouldBe(0);
|
||||
client.RangeReadLog[0].ByteCount.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gap_larger_than_bridge_threshold_splits_into_two_wire_calls()
|
||||
{
|
||||
// R0, R1, then R100, R101 — gap of 98 > bridge cap of 16 → 2 ranges.
|
||||
var tags = new[]
|
||||
{
|
||||
Tag("r0", "R0", FocasDataType.Byte),
|
||||
Tag("r1", "R1", FocasDataType.Byte),
|
||||
Tag("r100", "R100", FocasDataType.Byte),
|
||||
Tag("r101", "R101", FocasDataType.Byte),
|
||||
};
|
||||
var (drv, _, client) = NewDriver(tags);
|
||||
client.PmcByteRanges[("R", 1)] = new byte[200];
|
||||
client.PmcByteRanges[("R", 1)][0] = 10;
|
||||
client.PmcByteRanges[("R", 1)][1] = 11;
|
||||
client.PmcByteRanges[("R", 1)][100] = 20;
|
||||
client.PmcByteRanges[("R", 1)][101] = 21;
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["r0", "r1", "r100", "r101"], CancellationToken.None);
|
||||
|
||||
snapshots.Count.ShouldBe(4);
|
||||
foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots[0].Value.ShouldBe((sbyte)10);
|
||||
snapshots[3].Value.ShouldBe((sbyte)21);
|
||||
client.RangeReadLog.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Different_letters_yield_separate_wire_calls()
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
Tag("r0", "R0", FocasDataType.Byte),
|
||||
Tag("r1", "R1", FocasDataType.Byte),
|
||||
Tag("d0", "D0", FocasDataType.Byte),
|
||||
Tag("d1", "D1", FocasDataType.Byte),
|
||||
};
|
||||
var (drv, _, client) = NewDriver(tags);
|
||||
client.PmcByteRanges[("R", 1)] = new byte[8] { 1, 2, 3, 4, 5, 6, 7, 8 };
|
||||
client.PmcByteRanges[("D", 1)] = new byte[8] { 9, 10, 11, 12, 13, 14, 15, 16 };
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["r0", "r1", "d0", "d1"], CancellationToken.None);
|
||||
|
||||
foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots[0].Value.ShouldBe((sbyte)1);
|
||||
snapshots[2].Value.ShouldBe((sbyte)9);
|
||||
client.RangeReadLog.Count.ShouldBe(2);
|
||||
client.RangeReadLog.ShouldContain(c => c.Letter == "R");
|
||||
client.RangeReadLog.ShouldContain(c => c.Letter == "D");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Different_paths_yield_separate_wire_calls()
|
||||
{
|
||||
var tags = new[]
|
||||
{
|
||||
Tag("p1a", "R0", FocasDataType.Byte),
|
||||
Tag("p1b", "R1", FocasDataType.Byte),
|
||||
Tag("p2a", "R0@2", FocasDataType.Byte),
|
||||
Tag("p2b", "R1@2", FocasDataType.Byte),
|
||||
};
|
||||
var (drv, _, client) = NewDriver(tags);
|
||||
client.PathCount = 2;
|
||||
client.PmcByteRanges[("R", 1)] = new byte[] { 1, 2 };
|
||||
client.PmcByteRanges[("R", 2)] = new byte[] { 7, 8 };
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["p1a", "p1b", "p2a", "p2b"], CancellationToken.None);
|
||||
|
||||
foreach (var s in snapshots) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots[0].Value.ShouldBe((sbyte)1);
|
||||
snapshots[2].Value.ShouldBe((sbyte)7);
|
||||
client.RangeReadLog.Count.ShouldBe(2);
|
||||
client.RangeReadLog.ShouldContain(c => c.PathId == 1);
|
||||
client.RangeReadLog.ShouldContain(c => c.PathId == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Single_PMC_tag_does_not_invoke_range_path()
|
||||
{
|
||||
// Single-tag PMC reads aren't worth coalescing — the driver falls through to the
|
||||
// existing per-tag dispatch so connect/set-path overhead isn't paid twice.
|
||||
var (drv, _, client) = NewDriver(Tag("only", "R5", FocasDataType.Byte));
|
||||
client.Values["R5"] = (sbyte)42;
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["only"], CancellationToken.None);
|
||||
|
||||
snapshots.Single().Value.ShouldBe((sbyte)42);
|
||||
client.RangeReadLog.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_addressed_tags_share_parent_byte_range()
|
||||
{
|
||||
// R10.0 + R10.3 + R10.7 + R11.0 — all addressing R10 / R11, one coalesced range.
|
||||
var tags = new[]
|
||||
{
|
||||
Tag("b0", "R10.0", FocasDataType.Bit),
|
||||
Tag("b3", "R10.3", FocasDataType.Bit),
|
||||
Tag("b7", "R10.7", FocasDataType.Bit),
|
||||
Tag("c0", "R11.0", FocasDataType.Bit),
|
||||
};
|
||||
var (drv, _, client) = NewDriver(tags);
|
||||
client.PmcByteRanges[("R", 1)] = new byte[20];
|
||||
// R10 = 0b1000_1001 → bit0=1, bit3=1, bit7=1
|
||||
client.PmcByteRanges[("R", 1)][10] = 0b1000_1001;
|
||||
// R11 = 0b0000_0000 → bit0=0
|
||||
client.PmcByteRanges[("R", 1)][11] = 0;
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["b0", "b3", "b7", "c0"], CancellationToken.None);
|
||||
|
||||
snapshots[0].Value.ShouldBe(true);
|
||||
snapshots[1].Value.ShouldBe(true);
|
||||
snapshots[2].Value.ShouldBe(true);
|
||||
snapshots[3].Value.ShouldBe(false);
|
||||
client.RangeReadLog.Count.ShouldBe(1);
|
||||
client.RangeReadLog[0].StartByte.ShouldBe(10);
|
||||
client.RangeReadLog[0].ByteCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Wider_types_decode_correctly_from_coalesced_buffer()
|
||||
{
|
||||
// R0 Int16 + R2 Int32 + R6 Byte — contiguous (R0..R6 = 7 bytes), one range.
|
||||
var tags = new[]
|
||||
{
|
||||
Tag("w16", "R0", FocasDataType.Int16),
|
||||
Tag("w32", "R2", FocasDataType.Int32),
|
||||
Tag("b", "R6", FocasDataType.Byte),
|
||||
};
|
||||
var (drv, _, client) = NewDriver(tags);
|
||||
// Little-endian: R0..R1 = 0x1234 → bytes 0x34, 0x12; R2..R5 = 0x12345678 → 0x78,0x56,0x34,0x12; R6=0x05
|
||||
client.PmcByteRanges[("R", 1)] = new byte[] { 0x34, 0x12, 0x78, 0x56, 0x34, 0x12, 0x05 };
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["w16", "w32", "b"], CancellationToken.None);
|
||||
|
||||
snapshots[0].Value.ShouldBe((short)0x1234);
|
||||
snapshots[1].Value.ShouldBe(0x12345678);
|
||||
snapshots[2].Value.ShouldBe((sbyte)0x05);
|
||||
client.RangeReadLog.Count.ShouldBe(1);
|
||||
client.RangeReadLog[0].ByteCount.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Non_PMC_tags_in_same_batch_use_per_tag_path()
|
||||
{
|
||||
// Mix PMC + Parameter + Macro — only the PMC half coalesces.
|
||||
var tags = new[]
|
||||
{
|
||||
Tag("r0", "R0", FocasDataType.Byte),
|
||||
Tag("r1", "R1", FocasDataType.Byte),
|
||||
Tag("p", "PARAM:1820", FocasDataType.Int32),
|
||||
Tag("m", "MACRO:500", FocasDataType.Float64),
|
||||
};
|
||||
var (drv, _, client) = NewDriver(tags);
|
||||
client.PmcByteRanges[("R", 1)] = new byte[] { 11, 22 };
|
||||
client.Values["PARAM:1820"] = 7777;
|
||||
client.Values["MACRO:500"] = 2.71828;
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["r0", "r1", "p", "m"], CancellationToken.None);
|
||||
|
||||
snapshots[0].Value.ShouldBe((sbyte)11);
|
||||
snapshots[1].Value.ShouldBe((sbyte)22);
|
||||
snapshots[2].Value.ShouldBe(7777);
|
||||
snapshots[3].Value.ShouldBe(2.71828);
|
||||
client.RangeReadLog.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasPmcCoalescerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Empty_input_yields_no_groups()
|
||||
{
|
||||
var groups = FocasPmcCoalescer.Plan(Array.Empty<PmcAddressRequest>());
|
||||
groups.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Contiguous_same_letter_same_path_coalesces_into_one_group()
|
||||
{
|
||||
// 100 contiguous R-letter byte reads at byte 0..99
|
||||
var requests = new List<PmcAddressRequest>();
|
||||
for (var i = 0; i < 100; i++)
|
||||
requests.Add(new PmcAddressRequest("R", PathId: 1, ByteNumber: i, ByteWidth: 1, OriginalIndex: i));
|
||||
|
||||
var groups = FocasPmcCoalescer.Plan(requests);
|
||||
|
||||
groups.Count.ShouldBe(1);
|
||||
var g = groups[0];
|
||||
g.Letter.ShouldBe("R");
|
||||
g.PathId.ShouldBe(1);
|
||||
g.StartByte.ShouldBe(0);
|
||||
g.ByteCount.ShouldBe(100);
|
||||
g.Members.Count.ShouldBe(100);
|
||||
g.Members[42].Offset.ShouldBe(42);
|
||||
g.Members[42].OriginalIndex.ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Range_cap_splits_oversized_runs_into_multiple_groups()
|
||||
{
|
||||
// 300 contiguous bytes — must split (cap = 256)
|
||||
var requests = new List<PmcAddressRequest>();
|
||||
for (var i = 0; i < 300; i++)
|
||||
requests.Add(new PmcAddressRequest("R", 1, i, 1, i));
|
||||
|
||||
var groups = FocasPmcCoalescer.Plan(requests);
|
||||
|
||||
groups.Count.ShouldBe(2);
|
||||
groups[0].ByteCount.ShouldBe(FocasPmcCoalescer.MaxRangeBytes);
|
||||
groups[0].StartByte.ShouldBe(0);
|
||||
groups[1].StartByte.ShouldBe(FocasPmcCoalescer.MaxRangeBytes);
|
||||
groups[1].ByteCount.ShouldBe(300 - FocasPmcCoalescer.MaxRangeBytes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gap_within_bridge_threshold_is_bridged()
|
||||
{
|
||||
// Two runs: R0..R9 then R20..R29 — gap = 10 bytes, within bridge cap of 16.
|
||||
var requests = new List<PmcAddressRequest>
|
||||
{
|
||||
new("R", 1, 0, 1, 0),
|
||||
new("R", 1, 9, 1, 1),
|
||||
new("R", 1, 20, 1, 2),
|
||||
new("R", 1, 29, 1, 3),
|
||||
};
|
||||
|
||||
var groups = FocasPmcCoalescer.Plan(requests);
|
||||
|
||||
groups.Count.ShouldBe(1);
|
||||
groups[0].StartByte.ShouldBe(0);
|
||||
groups[0].ByteCount.ShouldBe(30);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gap_larger_than_bridge_threshold_splits()
|
||||
{
|
||||
// Two runs: R0 then R100 — gap of 99 bytes >> 16, must split.
|
||||
var requests = new List<PmcAddressRequest>
|
||||
{
|
||||
new("R", 1, 0, 1, 0),
|
||||
new("R", 1, 100, 1, 1),
|
||||
};
|
||||
|
||||
var groups = FocasPmcCoalescer.Plan(requests);
|
||||
|
||||
groups.Count.ShouldBe(2);
|
||||
groups[0].StartByte.ShouldBe(0);
|
||||
groups[1].StartByte.ShouldBe(100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_letters_split_into_separate_groups()
|
||||
{
|
||||
var requests = new List<PmcAddressRequest>
|
||||
{
|
||||
new("R", 1, 0, 1, 0),
|
||||
new("R", 1, 1, 1, 1),
|
||||
new("D", 1, 0, 1, 2),
|
||||
new("D", 1, 1, 1, 3),
|
||||
};
|
||||
|
||||
var groups = FocasPmcCoalescer.Plan(requests);
|
||||
|
||||
groups.Count.ShouldBe(2);
|
||||
groups.ShouldContain(g => g.Letter == "R" && g.ByteCount == 2);
|
||||
groups.ShouldContain(g => g.Letter == "D" && g.ByteCount == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_paths_split_into_separate_groups()
|
||||
{
|
||||
var requests = new List<PmcAddressRequest>
|
||||
{
|
||||
new("R", 1, 0, 1, 0),
|
||||
new("R", 1, 1, 1, 1),
|
||||
new("R", 2, 0, 1, 2),
|
||||
new("R", 2, 1, 1, 3),
|
||||
};
|
||||
|
||||
var groups = FocasPmcCoalescer.Plan(requests);
|
||||
|
||||
groups.Count.ShouldBe(2);
|
||||
groups.ShouldContain(g => g.Letter == "R" && g.PathId == 1);
|
||||
groups.ShouldContain(g => g.Letter == "R" && g.PathId == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Wider_data_types_extend_range_correctly()
|
||||
{
|
||||
// R0 is Int32 (4 bytes covers R0..R3), R4 is Byte → contiguous, one group of 5 bytes.
|
||||
var requests = new List<PmcAddressRequest>
|
||||
{
|
||||
new("R", 1, 0, ByteWidth: 4, 0),
|
||||
new("R", 1, 4, ByteWidth: 1, 1),
|
||||
};
|
||||
|
||||
var groups = FocasPmcCoalescer.Plan(requests);
|
||||
|
||||
groups.Count.ShouldBe(1);
|
||||
groups[0].ByteCount.ShouldBe(5);
|
||||
groups[0].Members[0].ByteWidth.ShouldBe(4);
|
||||
groups[0].Members[0].Offset.ShouldBe(0);
|
||||
groups[0].Members[1].ByteWidth.ShouldBe(1);
|
||||
groups[0].Members[1].Offset.ShouldBe(4);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Overlapping_requests_do_not_grow_range_beyond_their_union()
|
||||
{
|
||||
// R10 Int32 (R10..R13) + R12 Byte — overlap; range should still be 4 bytes from 10.
|
||||
var requests = new List<PmcAddressRequest>
|
||||
{
|
||||
new("R", 1, 10, 4, 0),
|
||||
new("R", 1, 12, 1, 1),
|
||||
};
|
||||
|
||||
var groups = FocasPmcCoalescer.Plan(requests);
|
||||
|
||||
groups.Count.ShouldBe(1);
|
||||
groups[0].StartByte.ShouldBe(10);
|
||||
groups[0].ByteCount.ShouldBe(4);
|
||||
groups[0].Members[1].Offset.ShouldBe(2); // member at byte 12, offset within range = 2
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ByteWidth_helper_matches_data_type_sizes()
|
||||
{
|
||||
FocasPmcCoalescer.ByteWidth(FocasDataType.Bit).ShouldBe(1);
|
||||
FocasPmcCoalescer.ByteWidth(FocasDataType.Byte).ShouldBe(1);
|
||||
FocasPmcCoalescer.ByteWidth(FocasDataType.Int16).ShouldBe(2);
|
||||
FocasPmcCoalescer.ByteWidth(FocasDataType.Int32).ShouldBe(4);
|
||||
FocasPmcCoalescer.ByteWidth(FocasDataType.Float32).ShouldBe(4);
|
||||
FocasPmcCoalescer.ByteWidth(FocasDataType.Float64).ShouldBe(8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasProductionFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns a configurable
|
||||
/// <see cref="FocasProductionInfo"/> snapshot from <c>GetProductionAsync</c>.
|
||||
/// </summary>
|
||||
private sealed class ProductionAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasProductionInfo? Production { get; set; }
|
||||
|
||||
Task<FocasProductionInfo?> IFocasClient.GetProductionAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Production);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Production_folder_with_4_Int32_nodes_per_device()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Production" && f.DisplayName == "Production");
|
||||
var prodVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Production/")).ToList();
|
||||
prodVars.Count.ShouldBe(4);
|
||||
string[] expected = ["PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds"];
|
||||
foreach (var name in expected)
|
||||
{
|
||||
var node = prodVars.SingleOrDefault(v => v.BrowseName == name);
|
||||
node.BrowseName.ShouldBe(name);
|
||||
node.Info.DriverDataType.ShouldBe(DriverDataType.Int32);
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
node.Info.FullName.ShouldBe($"{Host}::Production/{name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_each_Production_field_from_cached_snapshot()
|
||||
{
|
||||
var fake = new ProductionAwareFakeFocasClient
|
||||
{
|
||||
Production = new FocasProductionInfo(
|
||||
PartsProduced: 17,
|
||||
PartsRequired: 100,
|
||||
PartsTotal: 4242,
|
||||
CycleTimeSeconds: 73),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least one probe tick to populate the cache.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Production/PartsProduced"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Production/PartsProduced",
|
||||
$"{Host}::Production/PartsRequired",
|
||||
$"{Host}::Production/PartsTotal",
|
||||
$"{Host}::Production/CycleTimeSeconds",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
snaps[0].Value.ShouldBe(17);
|
||||
snaps[1].Value.ShouldBe(100);
|
||||
snaps[2].Value.ShouldBe(4242);
|
||||
snaps[3].Value.ShouldBe(73);
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_production_cache_is_empty()
|
||||
{
|
||||
// Probe disabled — cache never populates; the production nodes still resolve as
|
||||
// known references but report Bad until the first successful poll lands.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Production/PartsProduced"], CancellationToken.None);
|
||||
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetProductionAsync_returns_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); calling GetProductionAsync on the
|
||||
// unconnected client must not P/Invoke. Returns null → driver leaves the cache
|
||||
// in its current state.
|
||||
var client = new FwlibFocasClient();
|
||||
var result = await client.GetProductionAsync(CancellationToken.None);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasStatusFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns a configurable
|
||||
/// <see cref="FocasStatusInfo"/> snapshot from <see cref="GetStatusAsync"/>. Probe
|
||||
/// keeps its existing boolean semantic so the back-compat path stays exercised.
|
||||
/// </summary>
|
||||
private sealed class StatusAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasStatusInfo? Status { get; set; }
|
||||
|
||||
// Shadow the default interface implementation with a real one. Explicit interface
|
||||
// form so callers via IFocasClient hit this override; FakeFocasClient itself
|
||||
// doesn't declare a virtual GetStatusAsync (the contract has a default impl).
|
||||
Task<FocasStatusInfo?> IFocasClient.GetStatusAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Status_folder_with_9_Int16_nodes_per_device()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Status" && f.DisplayName == "Status");
|
||||
var statusVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Status/")).ToList();
|
||||
statusVars.Count.ShouldBe(9);
|
||||
string[] expected = ["Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy"];
|
||||
foreach (var name in expected)
|
||||
{
|
||||
var node = statusVars.SingleOrDefault(v => v.BrowseName == name);
|
||||
node.BrowseName.ShouldBe(name);
|
||||
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
node.Info.FullName.ShouldBe($"{Host}::Status/{name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_each_Status_field_from_cached_ODBST_snapshot()
|
||||
{
|
||||
var fake = new StatusAwareFakeFocasClient
|
||||
{
|
||||
Status = new FocasStatusInfo(
|
||||
Dummy: 0, Tmmode: 1, Aut: 2, Run: 3, Motion: 4,
|
||||
Mstb: 5, EmergencyStop: 1, Alarm: 7, Edit: 6),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least one probe tick to populate the cache.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Status/Tmmode"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Status/Tmmode",
|
||||
$"{Host}::Status/Aut",
|
||||
$"{Host}::Status/Run",
|
||||
$"{Host}::Status/Motion",
|
||||
$"{Host}::Status/Mstb",
|
||||
$"{Host}::Status/EmergencyStop",
|
||||
$"{Host}::Status/Alarm",
|
||||
$"{Host}::Status/Edit",
|
||||
$"{Host}::Status/Dummy",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
snaps[0].Value.ShouldBe((short)1); // Tmmode
|
||||
snaps[1].Value.ShouldBe((short)2); // Aut
|
||||
snaps[2].Value.ShouldBe((short)3); // Run
|
||||
snaps[3].Value.ShouldBe((short)4); // Motion
|
||||
snaps[4].Value.ShouldBe((short)5); // Mstb
|
||||
snaps[5].Value.ShouldBe((short)1); // EmergencyStop
|
||||
snaps[6].Value.ShouldBe((short)7); // Alarm
|
||||
snaps[7].Value.ShouldBe((short)6); // Edit
|
||||
snaps[8].Value.ShouldBe((short)0); // Dummy
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_status_cache_is_empty()
|
||||
{
|
||||
// Probe disabled — cache never populates; the status nodes still resolve as
|
||||
// known references but report Bad until the first successful poll lands.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Status/Tmmode"], CancellationToken.None);
|
||||
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Existing_boolean_probe_path_still_works_alongside_GetStatusAsync()
|
||||
{
|
||||
// Back-compat guard: ProbeAsync's existing boolean contract is preserved. A client
|
||||
// that doesn't override GetStatusAsync (default null) leaves the cache untouched
|
||||
// but the probe still flips host state to Running.
|
||||
var fake = new FakeFocasClient { ProbeResult = true };
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(() => Task.FromResult(
|
||||
drv.GetHostStatuses().Any(h => h.State == HostState.Running)),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
// No GetStatusAsync override → cache stays empty → status nodes report Bad,
|
||||
// but the rest of the driver keeps functioning.
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Status/Tmmode"], CancellationToken.None)).Single();
|
||||
snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetStatusAsync_returns_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); calling GetStatusAsync on the
|
||||
// unconnected client must not P/Invoke. Returns null → driver leaves the cache
|
||||
// in its current state.
|
||||
var client = new FwlibFocasClient();
|
||||
var result = await client.GetStatusAsync(CancellationToken.None);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasToolingOffsetsFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.7:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
||||
/// <see cref="FocasToolingInfo"/> + <see cref="FocasWorkOffsetsInfo"/> snapshots
|
||||
/// for the F1-d Tooling/CurrentTool + Offsets/ fixed-tree (issue #260).
|
||||
/// </summary>
|
||||
private sealed class ToolingAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasToolingInfo? Tooling { get; set; }
|
||||
public FocasWorkOffsetsInfo? WorkOffsets { get; set; }
|
||||
|
||||
Task<FocasToolingInfo?> IFocasClient.GetToolingAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Tooling);
|
||||
|
||||
Task<FocasWorkOffsetsInfo?> IFocasClient.GetWorkOffsetsAsync(CancellationToken ct) =>
|
||||
Task.FromResult(WorkOffsets);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Tooling_folder_with_CurrentTool_node()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-tooling", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Tooling" && f.DisplayName == "Tooling");
|
||||
var toolingVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Tooling/")).ToList();
|
||||
toolingVars.Count.ShouldBe(1);
|
||||
var node = toolingVars.Single();
|
||||
node.BrowseName.ShouldBe("CurrentTool");
|
||||
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
node.Info.FullName.ShouldBe($"{Host}::Tooling/CurrentTool");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Offsets_folder_with_G54_to_G59_each_with_3_axes()
|
||||
{
|
||||
// Six standard slots (G54..G59) * three axes (X/Y/Z) = 18 Float64 nodes per
|
||||
// device. Extended G54.1 P1..P48 deferred per the F1-d plan.
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-offsets", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Offsets");
|
||||
string[] expectedSlots = ["G54", "G55", "G56", "G57", "G58", "G59"];
|
||||
foreach (var slot in expectedSlots)
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == slot);
|
||||
var offsetVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Offsets/")).ToList();
|
||||
offsetVars.Count.ShouldBe(6 * 3);
|
||||
foreach (var slot in expectedSlots)
|
||||
foreach (var axis in new[] { "X", "Y", "Z" })
|
||||
{
|
||||
var fullRef = $"{Host}::Offsets/{slot}/{axis}";
|
||||
var node = offsetVars.SingleOrDefault(v => v.Info.FullName == fullRef);
|
||||
node.Info.DriverDataType.ShouldBe(DriverDataType.Float64);
|
||||
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_Tooling_and_Offsets_fields_from_cached_snapshot()
|
||||
{
|
||||
var fake = new ToolingAwareFakeFocasClient
|
||||
{
|
||||
Tooling = new FocasToolingInfo(CurrentTool: 17),
|
||||
WorkOffsets = new FocasWorkOffsetsInfo(
|
||||
[
|
||||
new FocasWorkOffset("G54", X: 100.5, Y: 200.25, Z: -50.0),
|
||||
new FocasWorkOffset("G55", X: 0, Y: 0, Z: 0),
|
||||
new FocasWorkOffset("G56", X: 0, Y: 0, Z: 0),
|
||||
new FocasWorkOffset("G57", X: 0, Y: 0, Z: 0),
|
||||
new FocasWorkOffset("G58", X: 0, Y: 0, Z: 0),
|
||||
new FocasWorkOffset("G59", X: 1, Y: 2, Z: 3),
|
||||
]),
|
||||
};
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
|
||||
}, "drv-tooling-read", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least one probe tick to populate both caches.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Tooling/CurrentTool"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Tooling/CurrentTool",
|
||||
$"{Host}::Offsets/G54/X",
|
||||
$"{Host}::Offsets/G54/Y",
|
||||
$"{Host}::Offsets/G54/Z",
|
||||
$"{Host}::Offsets/G59/X",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
snaps[0].Value.ShouldBe((short)17);
|
||||
snaps[1].Value.ShouldBe(100.5);
|
||||
snaps[2].Value.ShouldBe(200.25);
|
||||
snaps[3].Value.ShouldBe(-50.0);
|
||||
snaps[4].Value.ShouldBe(1.0);
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
|
||||
{
|
||||
// Probe disabled — neither tooling nor offsets caches populate; the nodes
|
||||
// still resolve as known references but report Bad until the first poll.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-empty-tooling", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Tooling/CurrentTool", $"{Host}::Offsets/G54/X"], CancellationToken.None);
|
||||
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetTooling_and_GetWorkOffsets_return_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); the unconnected client must
|
||||
// short-circuit before P/Invoke. Returns null → driver leaves the cache
|
||||
// untouched, matching the policy in f1a/f1b/f1c.
|
||||
var client = new FwlibFocasClient();
|
||||
(await client.GetToolingAsync(CancellationToken.None)).ShouldBeNull();
|
||||
(await client.GetWorkOffsetsAsync(CancellationToken.None)).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeOfsbAxis_applies_decimal_point_count_like_macro_decode()
|
||||
{
|
||||
// Layout per fwlib32.h: int data, short dec, short unit, short disp = 10 bytes.
|
||||
// Three axes (X=12345 / dec=3 = 12.345; Y=-500 / dec=2 = -5.00; Z=0 / dec=0 = 0).
|
||||
var buf = new byte[80];
|
||||
WriteAxis(buf, 0, raw: 12345, dec: 3);
|
||||
WriteAxis(buf, 1, raw: -500, dec: 2);
|
||||
WriteAxis(buf, 2, raw: 0, dec: 0);
|
||||
|
||||
FwlibFocasClient.DecodeOfsbAxis(buf, 0).ShouldBe(12.345, tolerance: 1e-9);
|
||||
FwlibFocasClient.DecodeOfsbAxis(buf, 1).ShouldBe(-5.0, tolerance: 1e-9);
|
||||
FwlibFocasClient.DecodeOfsbAxis(buf, 2).ShouldBe(0.0, tolerance: 1e-9);
|
||||
}
|
||||
|
||||
private static void WriteAxis(byte[] buf, int axisIndex, int raw, short dec)
|
||||
{
|
||||
var offset = axisIndex * 10;
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(buf.AsSpan(offset, 4), raw);
|
||||
System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(buf.AsSpan(offset + 4, 2), dec);
|
||||
}
|
||||
|
||||
private static async Task WaitForAsync(Func<Task<bool>> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!await condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
}
|
||||
|
||||
private sealed class RecordingBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
|
||||
public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();
|
||||
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
||||
{ Folders.Add((browseName, displayName)); return this; }
|
||||
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
|
||||
{ Variables.Add((browseName, info)); return new Handle(info.FullName); }
|
||||
|
||||
public void AddProperty(string _, DriverDataType __, object? ___) { }
|
||||
|
||||
private sealed class Handle(string fullRef) : IVariableHandle
|
||||
{
|
||||
public string FullReference => fullRef;
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
|
||||
}
|
||||
private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit coverage for the cert-validation knobs added in PR #277. Live revocation testing
|
||||
/// requires standing up a CA + CRL; we cover the parts that are testable without one:
|
||||
/// option defaults, the static decision pipeline, SHA-1 detection, and key-size checks.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientCertValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Defaults_match_documented_policy()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.CertificateValidation.RejectSHA1SignedCertificates.ShouldBeTrue(
|
||||
"SHA-1 is spec-deprecated for OPC UA — default must be hard-fail.");
|
||||
opts.CertificateValidation.RejectUnknownRevocationStatus.ShouldBeFalse(
|
||||
"Default must allow brownfield deployments without CRL infrastructure.");
|
||||
opts.CertificateValidation.MinimumCertificateKeySize.ShouldBe(2048);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Revoked_cert_is_rejected_even_when_AutoAccept_is_true()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.BadCertificateRevoked),
|
||||
autoAcceptUntrusted: true,
|
||||
new OpcUaCertificateValidationOptions());
|
||||
|
||||
decision.Accept.ShouldBeFalse();
|
||||
decision.LogMessage!.ShouldContain("REVOKED");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Issuer_revoked_is_rejected_even_when_AutoAccept_is_true()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.BadCertificateIssuerRevoked),
|
||||
autoAcceptUntrusted: true,
|
||||
new OpcUaCertificateValidationOptions());
|
||||
|
||||
decision.Accept.ShouldBeFalse();
|
||||
decision.LogMessage!.ShouldContain("REVOKED issuer");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevocationUnknown_default_accepts_with_log_note()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.BadCertificateRevocationUnknown),
|
||||
autoAcceptUntrusted: false,
|
||||
new OpcUaCertificateValidationOptions { RejectUnknownRevocationStatus = false });
|
||||
|
||||
decision.Accept.ShouldBeTrue();
|
||||
decision.LogMessage!.ShouldContain("revocation status unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RevocationUnknown_with_strict_flag_rejects()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.BadCertificateRevocationUnknown),
|
||||
autoAcceptUntrusted: true,
|
||||
new OpcUaCertificateValidationOptions { RejectUnknownRevocationStatus = true });
|
||||
|
||||
decision.Accept.ShouldBeFalse();
|
||||
decision.LogMessage!.ShouldContain("revocation status unknown");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sha1_signed_cert_is_rejected_by_default()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA1);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.Good),
|
||||
autoAcceptUntrusted: false,
|
||||
new OpcUaCertificateValidationOptions());
|
||||
|
||||
decision.Accept.ShouldBeFalse();
|
||||
decision.LogMessage!.ShouldContain("SHA-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Sha1_acceptance_can_be_opted_back_into()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA1);
|
||||
|
||||
// Untrusted + auto-accept = let it through; SHA-1 must NOT be the failing reason.
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.BadCertificateUntrusted),
|
||||
autoAcceptUntrusted: true,
|
||||
new OpcUaCertificateValidationOptions { RejectSHA1SignedCertificates = false });
|
||||
|
||||
decision.Accept.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Small_rsa_key_is_rejected_below_minimum()
|
||||
{
|
||||
using var cert = CreateRsaCert(1024, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.Good),
|
||||
autoAcceptUntrusted: false,
|
||||
new OpcUaCertificateValidationOptions());
|
||||
|
||||
decision.Accept.ShouldBeFalse();
|
||||
decision.LogMessage!.ShouldContain("1024");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetRsaKeySize_reports_correct_bit_count()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
OpcUaClientDriver.TryGetRsaKeySize(cert, out var bits).ShouldBeTrue();
|
||||
bits.ShouldBe(2048);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSha1Signed_detects_sha1_signature()
|
||||
{
|
||||
using var sha1Cert = CreateRsaCert(2048, HashAlgorithmName.SHA1);
|
||||
using var sha256Cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
OpcUaClientDriver.IsSha1Signed(sha1Cert).ShouldBeTrue();
|
||||
OpcUaClientDriver.IsSha1Signed(sha256Cert).ShouldBeFalse();
|
||||
OpcUaClientDriver.IsSha1Signed(null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Untrusted_without_AutoAccept_is_rejected()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.BadCertificateUntrusted),
|
||||
autoAcceptUntrusted: false,
|
||||
new OpcUaCertificateValidationOptions());
|
||||
|
||||
decision.Accept.ShouldBeFalse();
|
||||
decision.LogMessage!.ShouldContain("untrusted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Good_status_with_compliant_cert_accepts_silently()
|
||||
{
|
||||
using var cert = CreateRsaCert(2048, HashAlgorithmName.SHA256);
|
||||
|
||||
var decision = OpcUaClientDriver.EvaluateCertificateValidation(
|
||||
cert,
|
||||
new StatusCode(StatusCodes.Good),
|
||||
autoAcceptUntrusted: false,
|
||||
new OpcUaCertificateValidationOptions());
|
||||
|
||||
decision.Accept.ShouldBeTrue();
|
||||
decision.LogMessage.ShouldBeNull("Good validations shouldn't emit log noise.");
|
||||
}
|
||||
|
||||
private static X509Certificate2 CreateRsaCert(int keySize, HashAlgorithmName hash)
|
||||
{
|
||||
// .NET 10's CertificateRequest.CreateSelfSigned rejects SHA-1 outright. For the
|
||||
// SHA-256 path we use the supported API; for SHA-1 we route through a custom
|
||||
// X509SignatureGenerator that signs with SHA-1 OID so we can synthesise a SHA-1
|
||||
// signed cert in-process without shipping a binary fixture.
|
||||
var rsa = RSA.Create(keySize);
|
||||
var req = new CertificateRequest(
|
||||
new System.Security.Cryptography.X509Certificates.X500DistinguishedName(
|
||||
"CN=OpcUaClientCertValidationTests"),
|
||||
rsa,
|
||||
hash == HashAlgorithmName.SHA1 ? HashAlgorithmName.SHA256 : hash,
|
||||
RSASignaturePadding.Pkcs1);
|
||||
|
||||
if (hash == HashAlgorithmName.SHA1)
|
||||
{
|
||||
var generator = new Sha1RsaSignatureGenerator(rsa);
|
||||
var serial = new byte[8];
|
||||
System.Security.Cryptography.RandomNumberGenerator.Fill(serial);
|
||||
var built = req.Create(
|
||||
req.SubjectName,
|
||||
generator,
|
||||
DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
DateTimeOffset.UtcNow.AddHours(1),
|
||||
serial);
|
||||
// Combine cert + key so GetRSAPublicKey works downstream.
|
||||
return built.CopyWithPrivateKey(rsa);
|
||||
}
|
||||
|
||||
return req.CreateSelfSigned(
|
||||
DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
DateTimeOffset.UtcNow.AddHours(1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SHA-1 RSA signature generator. .NET 10's <see cref="X509SignatureGenerator.CreateForRSA"/>
|
||||
/// refuses SHA-1; we subclass to emit the SHA-1 RSA algorithm identifier
|
||||
/// (<c>1.2.840.113549.1.1.5</c>) and sign with SHA-1 explicitly. Test-only.
|
||||
/// </summary>
|
||||
private sealed class Sha1RsaSignatureGenerator : X509SignatureGenerator
|
||||
{
|
||||
private readonly RSA _rsa;
|
||||
public Sha1RsaSignatureGenerator(RSA rsa) { _rsa = rsa; }
|
||||
|
||||
public override byte[] GetSignatureAlgorithmIdentifier(HashAlgorithmName hashAlgorithm)
|
||||
{
|
||||
// DER: SEQUENCE { OID 1.2.840.113549.1.1.5, NULL }
|
||||
return new byte[]
|
||||
{
|
||||
0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x05, 0x05, 0x00,
|
||||
};
|
||||
}
|
||||
|
||||
public override byte[] SignData(byte[] data, HashAlgorithmName hashAlgorithm)
|
||||
=> _rsa.SignData(data, HashAlgorithmName.SHA1, RSASignaturePadding.Pkcs1);
|
||||
|
||||
protected override PublicKey BuildPublicKey() => PublicKey.CreateFromSubjectPublicKeyInfo(
|
||||
_rsa.ExportSubjectPublicKeyInfo(), out _);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the curation surface added in PR-7 (selective import + namespace
|
||||
/// remap + root-alias). Focused on the pure-function helpers
|
||||
/// (<see cref="OpcUaClientDriver.CompileGlobs"/>,
|
||||
/// <see cref="OpcUaClientDriver.ShouldInclude"/>,
|
||||
/// <see cref="OpcUaClientDriver.BuildRemappedFullName"/>) so the assertions don't need a
|
||||
/// live upstream server. End-to-end browse + filter coverage lands in the integration
|
||||
/// tests against opc-plc.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientCurationTests
|
||||
{
|
||||
[Fact]
|
||||
public void CurationOptions_default_is_empty_so_existing_behaviour_is_preserved()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.Curation.IncludePaths.ShouldBeNull();
|
||||
opts.Curation.ExcludePaths.ShouldBeNull();
|
||||
opts.Curation.NamespaceRemap.ShouldBeNull();
|
||||
opts.Curation.RootAlias.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldInclude_empty_include_means_include_all()
|
||||
{
|
||||
OpcUaClientDriver.ShouldInclude("Server/Status", include: null, exclude: null).ShouldBeTrue();
|
||||
OpcUaClientDriver.ShouldInclude("anything", include: null, exclude: null).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldInclude_star_glob_matches_any_segment_chars()
|
||||
{
|
||||
var include = OpcUaClientDriver.CompileGlobs(new[] { "Plant/*" });
|
||||
OpcUaClientDriver.ShouldInclude("Plant/Tank1", include, null).ShouldBeTrue();
|
||||
OpcUaClientDriver.ShouldInclude("Plant/Tank1/Level", include, null).ShouldBeTrue("'*' matches across slashes — single-glob simple semantics");
|
||||
OpcUaClientDriver.ShouldInclude("Other/Tank1", include, null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldInclude_question_mark_matches_exactly_one_char()
|
||||
{
|
||||
var include = OpcUaClientDriver.CompileGlobs(new[] { "Tank?" });
|
||||
OpcUaClientDriver.ShouldInclude("Tank1", include, null).ShouldBeTrue();
|
||||
OpcUaClientDriver.ShouldInclude("Tank12", include, null).ShouldBeFalse();
|
||||
OpcUaClientDriver.ShouldInclude("Tank", include, null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldInclude_exclude_wins_over_include()
|
||||
{
|
||||
var include = OpcUaClientDriver.CompileGlobs(new[] { "Plant/*" });
|
||||
var exclude = OpcUaClientDriver.CompileGlobs(new[] { "Plant/Diagnostics*" });
|
||||
OpcUaClientDriver.ShouldInclude("Plant/Tank1", include, exclude).ShouldBeTrue();
|
||||
OpcUaClientDriver.ShouldInclude("Plant/Diagnostics", include, exclude).ShouldBeFalse();
|
||||
OpcUaClientDriver.ShouldInclude("Plant/DiagnosticsTree/Sub", include, exclude).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldInclude_multiple_includes_are_OR()
|
||||
{
|
||||
var include = OpcUaClientDriver.CompileGlobs(new[] { "PlantA/*", "PlantB/*" });
|
||||
OpcUaClientDriver.ShouldInclude("PlantA/Tank", include, null).ShouldBeTrue();
|
||||
OpcUaClientDriver.ShouldInclude("PlantB/Tank", include, null).ShouldBeTrue();
|
||||
OpcUaClientDriver.ShouldInclude("PlantC/Tank", include, null).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompileGlobs_returns_null_for_empty_or_null_input()
|
||||
{
|
||||
OpcUaClientDriver.CompileGlobs(null).ShouldBeNull();
|
||||
OpcUaClientDriver.CompileGlobs(System.Array.Empty<string>()).ShouldBeNull();
|
||||
OpcUaClientDriver.CompileGlobs(new[] { string.Empty }).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompileGlobs_escapes_regex_metacharacters_in_literal_segments()
|
||||
{
|
||||
// A bare "." in the glob must NOT match arbitrary chars — it's escaped to a literal dot.
|
||||
var rx = OpcUaClientDriver.CompileGlobs(new[] { "App.exe" });
|
||||
rx.ShouldNotBeNull();
|
||||
rx!.IsMatch("App.exe").ShouldBeTrue();
|
||||
rx.IsMatch("AppXexe").ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRemappedFullName_returns_default_string_when_remap_is_empty()
|
||||
{
|
||||
var node = new NodeId("Tank/Level", 2);
|
||||
OpcUaClientDriver.BuildRemappedFullName(node, table: null, remap: null)
|
||||
.ShouldBe(node.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRemappedFullName_passes_through_namespace_zero_unchanged()
|
||||
{
|
||||
// Standard OPC UA nodes (ns=0) bypass remap so the base namespace round-trips.
|
||||
var node = new NodeId(2253u); // Server
|
||||
var remap = new Dictionary<string, string> { ["http://opcfoundation.org/UA/"] = "urn:local:base" };
|
||||
OpcUaClientDriver.BuildRemappedFullName(node, table: null, remap: remap)
|
||||
.ShouldBe(node.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRemappedFullName_translates_upstream_uri_when_remap_hits()
|
||||
{
|
||||
var table = new NamespaceTable();
|
||||
table.Append("urn:upstream:plc-vendor"); // index 1
|
||||
var node = new NodeId("Tank/Level", 1);
|
||||
var remap = new Dictionary<string, string>
|
||||
{
|
||||
["urn:upstream:plc-vendor"] = "urn:local:plant",
|
||||
};
|
||||
|
||||
var result = OpcUaClientDriver.BuildRemappedFullName(node, table, remap);
|
||||
|
||||
result.ShouldContain("urn:local:plant", Case.Sensitive);
|
||||
result.ShouldNotContain("urn:upstream:plc-vendor");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildRemappedFullName_no_op_when_upstream_uri_not_in_remap_table()
|
||||
{
|
||||
var table = new NamespaceTable();
|
||||
table.Append("urn:upstream:other"); // index 1
|
||||
var node = new NodeId("Some/Item", 1);
|
||||
var remap = new Dictionary<string, string>
|
||||
{
|
||||
["urn:not-matching"] = "urn:local:something",
|
||||
};
|
||||
OpcUaClientDriver.BuildRemappedFullName(node, table, remap).ShouldBe(node.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RootAlias_default_null_means_Remote()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.Curation.RootAlias.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RootAlias_can_be_set_via_Curation_record()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
Curation = new OpcUaClientCurationOptions(RootAlias: "Plant"),
|
||||
};
|
||||
opts.Curation.RootAlias.ShouldBe("Plant");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the per-driver diagnostic counters surfaced via
|
||||
/// <see cref="DriverHealth.Diagnostics"/> for the <c>driver-diagnostics</c> RPC
|
||||
/// (task #276). Counters are exercised directly through the internal helper rather
|
||||
/// than via a live SDK <c>ISession</c> because the SDK requires a connected upstream
|
||||
/// to publish events and we want unit-level coverage of the math + the snapshot shape.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientDiagnosticsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Counters_default_to_zero()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.PublishRequestCount.ShouldBe(0);
|
||||
d.NotificationCount.ShouldBe(0);
|
||||
d.NotificationsPerSecond.ShouldBe(0);
|
||||
d.MissingPublishRequestCount.ShouldBe(0);
|
||||
d.DroppedNotificationCount.ShouldBe(0);
|
||||
d.SessionResetCount.ShouldBe(0);
|
||||
d.LastReconnectUtc.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementPublishRequest_bumps_total()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.IncrementPublishRequest();
|
||||
d.IncrementPublishRequest();
|
||||
d.IncrementPublishRequest();
|
||||
d.PublishRequestCount.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementMissingPublishRequest_bumps_total()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.IncrementMissingPublishRequest();
|
||||
d.IncrementMissingPublishRequest();
|
||||
d.MissingPublishRequestCount.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncrementDroppedNotification_bumps_total()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.IncrementDroppedNotification();
|
||||
d.DroppedNotificationCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordNotification_grows_count_and_then_rate()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
// First sample seeds the EWMA — rate stays 0 until we have a delta.
|
||||
d.RecordNotification(t0);
|
||||
d.NotificationCount.ShouldBe(1);
|
||||
d.NotificationsPerSecond.ShouldBe(0);
|
||||
|
||||
// 1 Hz steady state: 30 samples spaced 1s apart converge toward 1/s. With 5s half-life
|
||||
// and alpha=0.5^(1/5)≈0.871, the EWMA approaches 1 - alpha^N — after 30 samples that's
|
||||
// 1 - 0.871^30 ≈ 0.984.
|
||||
for (var i = 1; i <= 30; i++)
|
||||
d.RecordNotification(t0.AddSeconds(i));
|
||||
|
||||
d.NotificationCount.ShouldBe(31);
|
||||
d.NotificationsPerSecond.ShouldBeInRange(0.95, 1.05, "EWMA at 5s half-life converges to ~1Hz after 30 samples");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordSessionReset_bumps_count_and_sets_last_reconnect()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
var t = new DateTime(2026, 4, 25, 12, 34, 56, DateTimeKind.Utc);
|
||||
d.RecordSessionReset(t);
|
||||
d.SessionResetCount.ShouldBe(1);
|
||||
d.LastReconnectUtc.ShouldBe(t);
|
||||
|
||||
// Second reset overwrites timestamp + bumps count.
|
||||
var t2 = t.AddMinutes(5);
|
||||
d.RecordSessionReset(t2);
|
||||
d.SessionResetCount.ShouldBe(2);
|
||||
d.LastReconnectUtc.ShouldBe(t2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_emits_well_known_keys()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.IncrementPublishRequest();
|
||||
d.RecordNotification(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
d.IncrementMissingPublishRequest();
|
||||
d.IncrementDroppedNotification();
|
||||
d.RecordSessionReset(new DateTime(2026, 4, 25, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
var snap = d.Snapshot();
|
||||
|
||||
snap.ShouldContainKey("PublishRequestCount");
|
||||
snap["PublishRequestCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("NotificationCount");
|
||||
snap["NotificationCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("NotificationsPerSecond");
|
||||
snap.ShouldContainKey("MissingPublishRequestCount");
|
||||
snap["MissingPublishRequestCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("DroppedNotificationCount");
|
||||
snap["DroppedNotificationCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("SessionResetCount");
|
||||
snap["SessionResetCount"].ShouldBe(1);
|
||||
snap.ShouldContainKey("LastReconnectUtcTicks");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Snapshot_omits_LastReconnectUtcTicks_when_no_reset_recorded()
|
||||
{
|
||||
var d = new OpcUaClientDiagnostics();
|
||||
d.Snapshot().ShouldNotContainKey("LastReconnectUtcTicks");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_GetHealth_includes_diagnostics_dictionary()
|
||||
{
|
||||
// GetHealth must expose the snapshot to the RPC consumer even before any session
|
||||
// has been opened — operators call it during startup to check counters baseline.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "diag-test");
|
||||
var health = drv.GetHealth();
|
||||
health.Diagnostics.ShouldNotBeNull();
|
||||
health.Diagnostics!.ShouldContainKey("PublishRequestCount");
|
||||
health.Diagnostics["PublishRequestCount"].ShouldBe(0);
|
||||
health.Diagnostics.ShouldContainKey("NotificationCount");
|
||||
health.Diagnostics.ShouldContainKey("SessionResetCount");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_health_diagnostics_reflect_internal_counters_after_increment()
|
||||
{
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "diag-test-2");
|
||||
|
||||
// Drive a counter through the test seam to prove the GetHealth snapshot is live,
|
||||
// not a one-shot at construction.
|
||||
drv.DiagnosticsForTest.IncrementPublishRequest();
|
||||
drv.DiagnosticsForTest.IncrementPublishRequest();
|
||||
|
||||
var health = drv.GetHealth();
|
||||
health.Diagnostics!["PublishRequestCount"].ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DriverHealth_default_diagnostics_is_null_but_DiagnosticsOrEmpty_is_empty()
|
||||
{
|
||||
// Back-compat: pre-existing call sites that construct DriverHealth with the
|
||||
// 3-arg overload must keep working — the 4th param defaults to null.
|
||||
var h = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
h.Diagnostics.ShouldBeNull();
|
||||
h.DiagnosticsOrEmpty.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,45 @@ public sealed class OpcUaClientDriverScaffoldTests
|
||||
health.LastError.ShouldNotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_subscription_tuning_matches_prior_hard_coded_values()
|
||||
{
|
||||
// PR #273: lifted hard-coded Subscription parameters into options; defaults MUST
|
||||
// remain wire-identical so existing deployments see no behaviour change.
|
||||
var subs = new OpcUaClientDriverOptions().Subscriptions;
|
||||
subs.KeepAliveCount.ShouldBe(10);
|
||||
subs.LifetimeCount.ShouldBe(1000u);
|
||||
subs.MaxNotificationsPerPublish.ShouldBe(0u, "0 = unlimited per OPC UA spec");
|
||||
subs.Priority.ShouldBe((byte)0);
|
||||
subs.MinPublishingIntervalMs.ShouldBe(50);
|
||||
subs.AlarmsPriority.ShouldBe((byte)1, "alarms get a higher priority than data tags so they aren't starved during bursts");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Subscription_defaults_are_overridable_via_options()
|
||||
{
|
||||
// Operators tuning a flaky-network deployment should be able to bump LifetimeCount /
|
||||
// lower MaxNotificationsPerPublish without recompiling the driver. Verify the record
|
||||
// is overridable end-to-end.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
Subscriptions = new OpcUaSubscriptionDefaults(
|
||||
KeepAliveCount: 25,
|
||||
LifetimeCount: 5000u,
|
||||
MaxNotificationsPerPublish: 200u,
|
||||
Priority: 7,
|
||||
MinPublishingIntervalMs: 100,
|
||||
AlarmsPriority: 9),
|
||||
};
|
||||
|
||||
opts.Subscriptions.KeepAliveCount.ShouldBe(25);
|
||||
opts.Subscriptions.LifetimeCount.ShouldBe(5000u);
|
||||
opts.Subscriptions.MaxNotificationsPerPublish.ShouldBe(200u);
|
||||
opts.Subscriptions.Priority.ShouldBe((byte)7);
|
||||
opts.Subscriptions.MinPublishingIntervalMs.ShouldBe(100);
|
||||
opts.Subscriptions.AlarmsPriority.ShouldBe((byte)9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reinitialize_against_unreachable_endpoint_re_throws()
|
||||
{
|
||||
|
||||
@@ -55,6 +55,85 @@ public sealed class OpcUaClientFailoverTests
|
||||
"pre-connect the dashboard should show the first candidate URL so operators can link back");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DiscoveryUrl_defaults_null_so_existing_configs_are_unaffected()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.DiscoveryUrl.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_prepends_discovered_urls_before_static_candidates()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://static1:4840", "opc.tcp://static2:4841"],
|
||||
};
|
||||
var discovered = new[] { "opc.tcp://discovered1:4840", "opc.tcp://discovered2:4841" };
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
|
||||
list.Count.ShouldBe(4);
|
||||
list[0].ShouldBe("opc.tcp://discovered1:4840");
|
||||
list[1].ShouldBe("opc.tcp://discovered2:4841");
|
||||
list[2].ShouldBe("opc.tcp://static1:4840");
|
||||
list[3].ShouldBe("opc.tcp://static2:4841");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_dedupes_url_appearing_in_both_discovered_and_static()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://shared:4840", "opc.tcp://static:4841"],
|
||||
};
|
||||
var discovered = new[] { "opc.tcp://shared:4840", "opc.tcp://only-discovered:4842" };
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
|
||||
list.Count.ShouldBe(3);
|
||||
list[0].ShouldBe("opc.tcp://shared:4840");
|
||||
list[1].ShouldBe("opc.tcp://only-discovered:4842");
|
||||
list[2].ShouldBe("opc.tcp://static:4841");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_dedup_is_case_insensitive()
|
||||
{
|
||||
// Discovery URLs sometimes return uppercase hostnames; static config typically has
|
||||
// lowercase. The de-dup should treat them as the same URL so the failover sweep
|
||||
// doesn't attempt the same host twice in a row.
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://host:4840"],
|
||||
};
|
||||
var discovered = new[] { "OPC.TCP://HOST:4840" };
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
|
||||
list.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_with_only_default_endpoint_is_replaced_by_discovery()
|
||||
{
|
||||
// No EndpointUrls list, default EndpointUrl — the static "candidate" is the default
|
||||
// localhost shortcut. When discovery returns URLs they should still be prepended
|
||||
// (the localhost default isn't worth filtering out specially since it's harmless to
|
||||
// try last and it's still a valid configured fallback).
|
||||
var opts = new OpcUaClientDriverOptions(); // EndpointUrl=opc.tcp://localhost:4840 default
|
||||
var discovered = new[] { "opc.tcp://discovered:4840" };
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, discovered);
|
||||
list[0].ShouldBe("opc.tcp://discovered:4840");
|
||||
list.ShouldContain("opc.tcp://localhost:4840");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEndpointCandidates_no_discovered_falls_back_to_static_behaviour()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions
|
||||
{
|
||||
EndpointUrls = ["opc.tcp://only:4840"],
|
||||
};
|
||||
var list = OpcUaClientDriver.ResolveEndpointCandidates(opts, []);
|
||||
list.Count.ShouldBe(1);
|
||||
list[0].ShouldBe("opc.tcp://only:4840");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_against_all_unreachable_endpoints_throws_AggregateException_listing_each()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the method-node mirror surface added in PR-9 (Issue #281). Live-call
|
||||
/// coverage of <see cref="OpcUaClientDriver.CallMethodAsync"/> against an upstream
|
||||
/// server lands in integration tests against opc-plc; these unit assertions cover the
|
||||
/// <see cref="IMethodInvoker"/> capability surface, the <see cref="MirroredMethodNodeInfo"/>
|
||||
/// DTO shape, the <see cref="MethodArgumentInfo"/> DTO shape, and the back-compat default
|
||||
/// no-op for <see cref="IAddressSpaceBuilder.RegisterMethodNode"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientMethodInvokerTests
|
||||
{
|
||||
[Fact]
|
||||
public void OpcUaClientDriver_implements_IMethodInvoker()
|
||||
{
|
||||
// The driver is the only built-in IMethodInvoker — tag-based drivers (Modbus, S7,
|
||||
// FOCAS, Galaxy, AB-CIP, AB-Legacy, TwinCAT) intentionally don't expose method nodes.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-method");
|
||||
drv.ShouldBeAssignableTo<IMethodInvoker>(
|
||||
"OPC UA Client driver is the cross-driver method-bearing capability");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CallMethodAsync_without_initialize_throws_InvalidOperationException()
|
||||
{
|
||||
// Same lifecycle invariant as the other capability surfaces — the session has to be
|
||||
// up before a call can land. Surfaces a clean exception rather than NullReference.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-uninit");
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await drv.CallMethodAsync(
|
||||
"ns=2;i=1", "ns=2;i=2",
|
||||
Array.Empty<object>(),
|
||||
TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MirroredMethodNodeInfo_carries_browse_identity_owner_and_argument_schemas()
|
||||
{
|
||||
// Exercises the address-space builder's contract for a method node — the DTO fields
|
||||
// line up with the OPC UA Call request shape (ObjectId + MethodId) and carry the
|
||||
// input/output Argument arrays the dispatcher uses to render the local method node's
|
||||
// signature.
|
||||
var inputs = new[]
|
||||
{
|
||||
new MethodArgumentInfo("targetTemperature", DriverDataType.Float64, ValueRank: -1, Description: "deg C"),
|
||||
};
|
||||
var outputs = new[]
|
||||
{
|
||||
new MethodArgumentInfo("ack", DriverDataType.Boolean, ValueRank: -1, Description: null),
|
||||
};
|
||||
var info = new MirroredMethodNodeInfo(
|
||||
BrowseName: "SetTemperature",
|
||||
DisplayName: "Set Temperature",
|
||||
ObjectNodeId: "ns=2;i=1",
|
||||
MethodNodeId: "ns=2;i=42",
|
||||
InputArguments: inputs,
|
||||
OutputArguments: outputs);
|
||||
|
||||
info.BrowseName.ShouldBe("SetTemperature");
|
||||
info.MethodNodeId.ShouldBe("ns=2;i=42");
|
||||
info.ObjectNodeId.ShouldBe("ns=2;i=1");
|
||||
info.InputArguments.ShouldNotBeNull().ShouldHaveSingleItem().Name.ShouldBe("targetTemperature");
|
||||
info.OutputArguments.ShouldNotBeNull().ShouldHaveSingleItem().DriverDataType.ShouldBe(DriverDataType.Boolean);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MirroredMethodNodeInfo_supports_methods_with_no_arguments()
|
||||
{
|
||||
// Argument-less methods (e.g. a Reset / Stop button) are common; null arrays mean
|
||||
// "no inputs / no outputs" — the dispatcher handles that without complaining.
|
||||
var info = new MirroredMethodNodeInfo(
|
||||
BrowseName: "Reset", DisplayName: "Reset",
|
||||
ObjectNodeId: "ns=2;i=1", MethodNodeId: "ns=2;i=99",
|
||||
InputArguments: null, OutputArguments: null);
|
||||
|
||||
info.InputArguments.ShouldBeNull();
|
||||
info.OutputArguments.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MethodCallResult_carries_status_outputs_and_per_input_argument_codes()
|
||||
{
|
||||
// The status-code passthrough contract: Bad codes flow verbatim from the upstream
|
||||
// so downstream OPC UA clients see canonical service-results (BadMethodInvalid,
|
||||
// BadUserAccessDenied, BadArgumentsMissing, …). Outputs + per-arg codes are
|
||||
// optional (null when the upstream didn't surface them).
|
||||
var goodCall = new MethodCallResult(StatusCode: 0, Outputs: new object[] { 42 }, InputArgumentResults: null);
|
||||
goodCall.StatusCode.ShouldBe(0u);
|
||||
goodCall.Outputs.ShouldNotBeNull().Length.ShouldBe(1);
|
||||
goodCall.InputArgumentResults.ShouldBeNull();
|
||||
|
||||
const uint BadMethodInvalid = 0x80540000u;
|
||||
var badCall = new MethodCallResult(BadMethodInvalid, Outputs: null, InputArgumentResults: null);
|
||||
badCall.StatusCode.ShouldBe(BadMethodInvalid);
|
||||
badCall.Outputs.ShouldBeNull();
|
||||
|
||||
const uint BadTypeMismatch = 0x80740000u;
|
||||
var argFailure = new MethodCallResult(
|
||||
StatusCode: BadTypeMismatch, Outputs: null,
|
||||
InputArgumentResults: new[] { 0u, BadTypeMismatch });
|
||||
argFailure.InputArgumentResults.ShouldNotBeNull();
|
||||
argFailure.InputArgumentResults![0].ShouldBe(0u);
|
||||
argFailure.InputArgumentResults![1].ShouldBe(BadTypeMismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterMethodNode_default_implementation_is_no_op_for_back_compat()
|
||||
{
|
||||
// Builders that only implement Folder + Variable + AddProperty (the pre-PR-9
|
||||
// contract) keep working — the default no-op on RegisterMethodNode means calling
|
||||
// it on a minimal builder doesn't throw. This protects every non-OPC-UA-Client
|
||||
// builder (Galaxy, Modbus, FOCAS, S7, TwinCAT, AB-CIP, AB-Legacy) from forced
|
||||
// override pressure.
|
||||
IAddressSpaceBuilder builder = new MinimalBuilder();
|
||||
var info = new MirroredMethodNodeInfo(
|
||||
BrowseName: "Reset", DisplayName: "Reset",
|
||||
ObjectNodeId: "ns=2;i=1", MethodNodeId: "ns=2;i=99",
|
||||
InputArguments: null, OutputArguments: null);
|
||||
|
||||
Should.NotThrow(() => builder.RegisterMethodNode(info));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterMethodNode_can_be_overridden_so_OPC_UA_server_builders_record_the_call()
|
||||
{
|
||||
// The OPC UA server-side DriverNodeManager will override RegisterMethodNode to
|
||||
// materialize a real MethodNode + wire its OnCallMethod handler to the driver's
|
||||
// CallMethodAsync. This test models that contract with a recording stub.
|
||||
var recorder = new RecordingMethodBuilder();
|
||||
IAddressSpaceBuilder builder = recorder;
|
||||
|
||||
builder.RegisterMethodNode(new MirroredMethodNodeInfo(
|
||||
BrowseName: "Start", DisplayName: "Start",
|
||||
ObjectNodeId: "ns=2;i=1", MethodNodeId: "ns=2;i=10",
|
||||
InputArguments: null, OutputArguments: null));
|
||||
builder.RegisterMethodNode(new MirroredMethodNodeInfo(
|
||||
BrowseName: "SetSetpoint", DisplayName: "SetSetpoint",
|
||||
ObjectNodeId: "ns=2;i=1", MethodNodeId: "ns=2;i=11",
|
||||
InputArguments: new[] { new MethodArgumentInfo("sp", DriverDataType.Float32, -1, null) },
|
||||
OutputArguments: null));
|
||||
|
||||
recorder.Calls.Count.ShouldBe(2);
|
||||
recorder.Calls[0].BrowseName.ShouldBe("Start");
|
||||
recorder.Calls[1].InputArguments.ShouldNotBeNull().ShouldHaveSingleItem().Name.ShouldBe("sp");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-PR-9 minimum implementation. Verifies the default no-op for
|
||||
/// <c>RegisterMethodNode</c> doesn't break existing builders that don't know about
|
||||
/// the new surface.
|
||||
/// </summary>
|
||||
private sealed class MinimalBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> new StubHandle();
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
public string FullReference => "stub";
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingMethodBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<MirroredMethodNodeInfo> Calls { get; } = new();
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> throw new NotSupportedException();
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
public void RegisterMethodNode(MirroredMethodNodeInfo info) => Calls.Add(info);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="MonitoredTagSpec"/> -> SDK <c>MonitoredItem</c> mapping.
|
||||
/// Assertion-only — no live SDK session required, so the tests run on every CI without
|
||||
/// a real OPC UA server fixture.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientMonitoredTagSpecTests
|
||||
{
|
||||
private static readonly NodeId SampleNodeId = new("Demo", 2);
|
||||
|
||||
[Fact]
|
||||
public void BuildMonitoredItem_with_all_defaults_matches_legacy_hard_coded_values()
|
||||
{
|
||||
// Spec with every per-tag knob null should behave identically to the legacy
|
||||
// string-only SubscribeAsync path: Reporting / SamplingInterval=publishInterval /
|
||||
// QueueSize=1 / DiscardOldest=true / no filter.
|
||||
var spec = new MonitoredTagSpec("ns=2;s=Demo");
|
||||
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
|
||||
|
||||
item.SamplingInterval.ShouldBe(250);
|
||||
item.QueueSize.ShouldBe(1u);
|
||||
item.DiscardOldest.ShouldBeTrue();
|
||||
item.MonitoringMode.ShouldBe(MonitoringMode.Reporting);
|
||||
item.Filter.ShouldBeNull();
|
||||
item.Handle.ShouldBe("ns=2;s=Demo",
|
||||
"the tag string is routed through Handle so the Notification callback can identify the changed tag without re-parsing DisplayName");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMonitoredItem_applies_per_tag_sampling_interval_independent_of_publish_interval()
|
||||
{
|
||||
// Per-tag SamplingInterval lets the server sample faster than it publishes — useful
|
||||
// for events that change between publish ticks. If the spec sets it explicitly, the
|
||||
// mapping uses that value, not the publish-interval default.
|
||||
var spec = new MonitoredTagSpec("ns=2;s=Fast", SamplingIntervalMs: 50);
|
||||
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 1000);
|
||||
item.SamplingInterval.ShouldBe(50);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMonitoredItem_applies_queue_size_and_discard_oldest_overrides()
|
||||
{
|
||||
var spec = new MonitoredTagSpec("ns=2;s=DeepQueue", QueueSize: 100, DiscardOldest: false);
|
||||
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
|
||||
item.QueueSize.ShouldBe(100u);
|
||||
item.DiscardOldest.ShouldBeFalse(
|
||||
"discard-oldest=false preserves earliest values — useful for audit-trail subscriptions where the first overflow sample is the most diagnostic");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SubscriptionMonitoringMode.Disabled, MonitoringMode.Disabled)]
|
||||
[InlineData(SubscriptionMonitoringMode.Sampling, MonitoringMode.Sampling)]
|
||||
[InlineData(SubscriptionMonitoringMode.Reporting, MonitoringMode.Reporting)]
|
||||
public void BuildMonitoredItem_maps_each_monitoring_mode(SubscriptionMonitoringMode input, MonitoringMode expected)
|
||||
{
|
||||
var spec = new MonitoredTagSpec("ns=2;s=Mode", MonitoringMode: input);
|
||||
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
|
||||
item.MonitoringMode.ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMonitoredItem_with_absolute_deadband_emits_DataChangeFilter()
|
||||
{
|
||||
var spec = new MonitoredTagSpec(
|
||||
"ns=2;s=Analog",
|
||||
DataChangeFilter: new DataChangeFilterSpec(
|
||||
Core.Abstractions.DataChangeTrigger.StatusValue,
|
||||
Core.Abstractions.DeadbandType.Absolute,
|
||||
DeadbandValue: 0.5));
|
||||
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
|
||||
var filter = item.Filter.ShouldBeOfType<DataChangeFilter>();
|
||||
filter.Trigger.ShouldBe(Opc.Ua.DataChangeTrigger.StatusValue);
|
||||
filter.DeadbandType.ShouldBe((uint)Opc.Ua.DeadbandType.Absolute);
|
||||
filter.DeadbandValue.ShouldBe(0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildMonitoredItem_with_percent_deadband_emits_percent_filter()
|
||||
{
|
||||
// PercentDeadband is calculated server-side as a fraction of EURange; the driver
|
||||
// emits the filter unconditionally and lets the server return BadFilterNotAllowed
|
||||
// if EURange isn't set on the variable. SubscribeAsync's catch-block swallows that
|
||||
// status so other items in the batch still get created.
|
||||
var spec = new MonitoredTagSpec(
|
||||
"ns=2;s=Pct",
|
||||
DataChangeFilter: new DataChangeFilterSpec(
|
||||
Core.Abstractions.DataChangeTrigger.StatusValueTimestamp,
|
||||
Core.Abstractions.DeadbandType.Percent,
|
||||
DeadbandValue: 5.0));
|
||||
var item = OpcUaClientDriver.BuildMonitoredItem(spec, SampleNodeId, defaultIntervalMs: 250);
|
||||
var filter = item.Filter.ShouldBeOfType<DataChangeFilter>();
|
||||
filter.Trigger.ShouldBe(Opc.Ua.DataChangeTrigger.StatusValueTimestamp);
|
||||
filter.DeadbandType.ShouldBe((uint)Opc.Ua.DeadbandType.Percent);
|
||||
filter.DeadbandValue.ShouldBe(5.0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(Core.Abstractions.DataChangeTrigger.Status, Opc.Ua.DataChangeTrigger.Status)]
|
||||
[InlineData(Core.Abstractions.DataChangeTrigger.StatusValue, Opc.Ua.DataChangeTrigger.StatusValue)]
|
||||
[InlineData(Core.Abstractions.DataChangeTrigger.StatusValueTimestamp, Opc.Ua.DataChangeTrigger.StatusValueTimestamp)]
|
||||
public void MapTrigger_round_trips_each_enum_value(
|
||||
Core.Abstractions.DataChangeTrigger input, Opc.Ua.DataChangeTrigger expected)
|
||||
=> OpcUaClientDriver.MapTrigger(input).ShouldBe(expected);
|
||||
|
||||
[Theory]
|
||||
[InlineData(Core.Abstractions.DeadbandType.None, Opc.Ua.DeadbandType.None)]
|
||||
[InlineData(Core.Abstractions.DeadbandType.Absolute, Opc.Ua.DeadbandType.Absolute)]
|
||||
[InlineData(Core.Abstractions.DeadbandType.Percent, Opc.Ua.DeadbandType.Percent)]
|
||||
public void MapDeadbandType_round_trips_each_enum_value(
|
||||
Core.Abstractions.DeadbandType input, Opc.Ua.DeadbandType expected)
|
||||
=> OpcUaClientDriver.MapDeadbandType(input).ShouldBe(expected);
|
||||
|
||||
[Fact]
|
||||
public async Task DefaultInterfaceImplementation_routes_through_legacy_overload()
|
||||
{
|
||||
// ISubscribable's default interface impl of the per-tag overload delegates to the
|
||||
// simple-string overload, ignoring per-tag knobs. Drivers that DON'T override the
|
||||
// new overload (Modbus / S7 / Galaxy / TwinCAT / FOCAS / AbCip / AbLegacy) still
|
||||
// accept MonitoredTagSpec lists and just pass through the tag names — back-compat
|
||||
// for ISubscribable consumers.
|
||||
var stub = new StubSubscribableDriver();
|
||||
var specs = new[]
|
||||
{
|
||||
new MonitoredTagSpec("Tag1", SamplingIntervalMs: 50, QueueSize: 5),
|
||||
new MonitoredTagSpec("Tag2", DataChangeFilter: new DataChangeFilterSpec(
|
||||
Core.Abstractions.DataChangeTrigger.StatusValue,
|
||||
Core.Abstractions.DeadbandType.Absolute,
|
||||
1.0)),
|
||||
};
|
||||
|
||||
ISubscribable iface = stub;
|
||||
_ = await iface.SubscribeAsync(specs, TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken);
|
||||
|
||||
stub.LastTagNames.ShouldBe(["Tag1", "Tag2"]);
|
||||
stub.LastPublishingInterval.ShouldBe(TimeSpan.FromMilliseconds(250));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-double <see cref="ISubscribable"/> that records whatever the legacy
|
||||
/// <c>SubscribeAsync(IReadOnlyList<string>, ...)</c> overload was called with.
|
||||
/// Used to verify the default-impl per-tag overload routes correctly without needing
|
||||
/// a real OPC UA session.
|
||||
/// </summary>
|
||||
private sealed class StubSubscribableDriver : ISubscribable
|
||||
{
|
||||
public IReadOnlyList<string>? LastTagNames { get; private set; }
|
||||
public TimeSpan LastPublishingInterval { get; private set; }
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
LastTagNames = fullReferences;
|
||||
LastPublishingInterval = publishingInterval;
|
||||
return Task.FromResult<ISubscriptionHandle>(new StubHandle());
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
#pragma warning disable CS0067 // event never used — the test only asserts the SubscribeAsync call routing
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
#pragma warning restore CS0067
|
||||
}
|
||||
|
||||
private sealed record StubHandle() : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => "stub";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the OperationLimits chunking surface (PR #275 / opcuaclient-3). Focused
|
||||
/// on the static <see cref="OpcUaClientDriver.ChunkBy{T}"/> helper + the
|
||||
/// <see cref="OpcUaClientDriver.OperationLimitsCache"/> sentinel semantics. Live
|
||||
/// end-to-end tests against an in-process server land in the integration suite.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientOperationLimitsTests
|
||||
{
|
||||
[Fact]
|
||||
public void ChunkBy_with_cap_5_splits_12_items_into_3_slices_of_5_5_2()
|
||||
{
|
||||
// The PR-3 acceptance scenario: server advertises MaxNodesPerRead=5, client batches a
|
||||
// 12-tag read; driver must issue exactly 3 wire calls of sizes 5/5/2 in order.
|
||||
var input = Enumerable.Range(0, 12).ToArray();
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 5).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(3);
|
||||
slices[0].Count.ShouldBe(5);
|
||||
slices[1].Count.ShouldBe(5);
|
||||
slices[2].Count.ShouldBe(2);
|
||||
// Order + offsets must reflect the original sequence — chunking must not reorder
|
||||
// tags, otherwise the indexMap ↔ result-index alignment breaks.
|
||||
slices[0].ShouldBe(new[] { 0, 1, 2, 3, 4 });
|
||||
slices[1].ShouldBe(new[] { 5, 6, 7, 8, 9 });
|
||||
slices[2].ShouldBe(new[] { 10, 11 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBy_with_null_cap_yields_single_slice_no_chunking()
|
||||
{
|
||||
// cap=null is the "fetch hasn't completed" / "server reports 0 = no limit" sentinel.
|
||||
// Both must collapse to a single SDK call so the wire path doesn't change when the
|
||||
// server doesn't impose a cap.
|
||||
var input = Enumerable.Range(0, 12).ToArray();
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: null).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(1, "null cap means no chunking — single SDK call");
|
||||
slices[0].Count.ShouldBe(12);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBy_with_zero_cap_yields_single_slice_no_chunking()
|
||||
{
|
||||
// OPC UA Part 5: 0 is the wire-level "no limit" sentinel. NormalizeLimit folds it
|
||||
// into null upstream of ChunkBy, but the chunker itself must also treat 0 as
|
||||
// no-chunking — defence in depth in case a caller bypasses NormalizeLimit.
|
||||
var input = Enumerable.Range(0, 7).ToArray();
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 0).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(1);
|
||||
slices[0].Count.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBy_with_cap_larger_than_input_yields_single_slice()
|
||||
{
|
||||
var input = new[] { 1, 2, 3 };
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 100).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(1);
|
||||
slices[0].Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBy_with_empty_input_yields_no_slices()
|
||||
{
|
||||
// Empty batch must short-circuit before the wire call — saves a round-trip and
|
||||
// matches the !toSend.Count == 0 guard in the driver.
|
||||
var input = Array.Empty<int>();
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 5).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBy_with_cap_equal_to_input_size_yields_single_slice()
|
||||
{
|
||||
// Edge case: exactly N items at cap N. Must NOT produce an extra empty slice.
|
||||
var input = Enumerable.Range(0, 5).ToArray();
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 5).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(1);
|
||||
slices[0].Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChunkBy_with_cap_1_splits_each_item_into_its_own_slice()
|
||||
{
|
||||
// Pathological cap — degrades to N wire calls. Verifies the chunker handles the
|
||||
// boundary cleanly without off-by-one.
|
||||
var input = new[] { 10, 20, 30 };
|
||||
|
||||
var slices = OpcUaClientDriver.ChunkBy<int>(input, cap: 1).ToArray();
|
||||
|
||||
slices.Length.ShouldBe(3);
|
||||
slices[0].ShouldBe(new[] { 10 });
|
||||
slices[1].ShouldBe(new[] { 20 });
|
||||
slices[2].ShouldBe(new[] { 30 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OperationLimitsCache_records_all_four_caps_as_nullable_uint()
|
||||
{
|
||||
// The cache surfaces the four limits the driver chunks against. Storing as uint?
|
||||
// lets the chunker distinguish "not yet fetched" / "no limit" (null) from "limit=N".
|
||||
var cache = new OpcUaClientDriver.OperationLimitsCache(
|
||||
MaxNodesPerRead: 100u,
|
||||
MaxNodesPerWrite: 50u,
|
||||
MaxNodesPerBrowse: null,
|
||||
MaxNodesPerHistoryReadData: 10u);
|
||||
|
||||
cache.MaxNodesPerRead.ShouldBe(100u);
|
||||
cache.MaxNodesPerWrite.ShouldBe(50u);
|
||||
cache.MaxNodesPerBrowse.ShouldBeNull();
|
||||
cache.MaxNodesPerHistoryReadData.ShouldBe(10u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Driver_starts_with_no_cached_OperationLimits()
|
||||
{
|
||||
// Pre-init / pre-first-batch state: cache is null so callers fall through to
|
||||
// single-call behaviour. Lazy fetch happens on the first ReadAsync/WriteAsync.
|
||||
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cache-init");
|
||||
|
||||
drv.OperationLimitsForTest.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the type-mirror surface added in PR-8 (Issue #280). Live-browse
|
||||
/// coverage of the Pass-3 walk against an upstream server lands in integration tests
|
||||
/// against opc-plc; these unit assertions cover the option default, the new
|
||||
/// <see cref="MirroredTypeNodeInfo"/> shape, and the back-compat default no-op for
|
||||
/// <see cref="IAddressSpaceBuilder.RegisterTypeNode"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class OpcUaClientTypeMirrorTests
|
||||
{
|
||||
[Fact]
|
||||
public void MirrorTypeDefinitions_default_is_false_so_existing_deployments_are_unchanged()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions();
|
||||
opts.MirrorTypeDefinitions.ShouldBeFalse(
|
||||
"opt-in flag — existing deployments shouldn't suddenly see a flood of type nodes");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MirrorTypeDefinitions_can_be_enabled_via_init()
|
||||
{
|
||||
var opts = new OpcUaClientDriverOptions { MirrorTypeDefinitions = true };
|
||||
opts.MirrorTypeDefinitions.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MirroredTypeNodeInfo_carries_kind_identity_supertype_and_abstract_flag()
|
||||
{
|
||||
var info = new MirroredTypeNodeInfo(
|
||||
Kind: MirroredTypeKind.ObjectType,
|
||||
UpstreamNodeId: "ns=2;i=1234",
|
||||
BrowseName: "MyMachine",
|
||||
DisplayName: "My Machine",
|
||||
SuperTypeNodeId: "i=58", // BaseObjectType
|
||||
IsAbstract: false);
|
||||
|
||||
info.Kind.ShouldBe(MirroredTypeKind.ObjectType);
|
||||
info.UpstreamNodeId.ShouldBe("ns=2;i=1234");
|
||||
info.BrowseName.ShouldBe("MyMachine");
|
||||
info.DisplayName.ShouldBe("My Machine");
|
||||
info.SuperTypeNodeId.ShouldBe("i=58");
|
||||
info.IsAbstract.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MirroredTypeKind_covers_all_four_OPC_UA_type_categories()
|
||||
{
|
||||
// The four standard OPC UA type-folder children — ObjectTypes (i=88),
|
||||
// VariableTypes (i=89), DataTypes (i=90), ReferenceTypes (i=91). Asserts the enum
|
||||
// shape rather than the values so the test catches accidental category renames.
|
||||
Enum.GetValues<MirroredTypeKind>().Length.ShouldBe(4);
|
||||
Enum.IsDefined(MirroredTypeKind.ObjectType).ShouldBeTrue();
|
||||
Enum.IsDefined(MirroredTypeKind.VariableType).ShouldBeTrue();
|
||||
Enum.IsDefined(MirroredTypeKind.DataType).ShouldBeTrue();
|
||||
Enum.IsDefined(MirroredTypeKind.ReferenceType).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterTypeNode_default_implementation_is_no_op_so_existing_builders_dont_break()
|
||||
{
|
||||
// Builders that only implement Folder + Variable + AddProperty (i.e. the pre-PR-8
|
||||
// contract) should continue to work — the default no-op on RegisterTypeNode means
|
||||
// calling it on a minimal builder doesn't throw.
|
||||
IAddressSpaceBuilder builder = new MinimalBuilder();
|
||||
var info = new MirroredTypeNodeInfo(MirroredTypeKind.DataType,
|
||||
UpstreamNodeId: "i=290", BrowseName: "Duration", DisplayName: "Duration",
|
||||
SuperTypeNodeId: "i=290", IsAbstract: false);
|
||||
|
||||
Should.NotThrow(() => builder.RegisterTypeNode(info));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterTypeNode_can_be_overridden_so_OPC_UA_server_builders_record_the_call()
|
||||
{
|
||||
// The OPC UA server-side DriverNodeManager will override RegisterTypeNode to actually
|
||||
// project the type into the local address space. This test models that contract with
|
||||
// a recording stub.
|
||||
var recorder = new RecordingTypeBuilder();
|
||||
IAddressSpaceBuilder builder = recorder;
|
||||
|
||||
builder.RegisterTypeNode(new MirroredTypeNodeInfo(
|
||||
MirroredTypeKind.ObjectType, "ns=2;i=1", "T1", "T1", null, false));
|
||||
builder.RegisterTypeNode(new MirroredTypeNodeInfo(
|
||||
MirroredTypeKind.VariableType, "ns=2;i=2", "V1", "V1", "i=63", true));
|
||||
|
||||
recorder.Calls.Count.ShouldBe(2);
|
||||
recorder.Calls[0].Kind.ShouldBe(MirroredTypeKind.ObjectType);
|
||||
recorder.Calls[1].IsAbstract.ShouldBeTrue();
|
||||
recorder.Calls[1].SuperTypeNodeId.ShouldBe("i=63");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimum back-compat implementation — only the pre-PR-8 surface is overridden.
|
||||
/// Verifies the default-implementation no-op for <c>RegisterTypeNode</c> doesn't break
|
||||
/// existing builders.
|
||||
/// </summary>
|
||||
private sealed class MinimalBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> new StubHandle();
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
|
||||
private sealed class StubHandle : IVariableHandle
|
||||
{
|
||||
public string FullReference => "stub";
|
||||
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RecordingTypeBuilder : IAddressSpaceBuilder
|
||||
{
|
||||
public List<MirroredTypeNodeInfo> Calls { get; } = new();
|
||||
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
||||
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
||||
=> throw new NotSupportedException();
|
||||
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
||||
public void RegisterTypeNode(MirroredTypeNodeInfo info) => Calls.Add(info);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end verification of the block-read coalescing planner (PR-S7-B2)
|
||||
/// against the python-snap7 S7-1500 simulator. The headline assertion: 50
|
||||
/// contiguous DBW reads (DB1.DBW0..DB1.DBW98) coalesce into exactly ONE
|
||||
/// <c>Plc.ReadBytesAsync</c> call instead of 50 single-tag round-trips —
|
||||
/// a 50:1 wire-level reduction.
|
||||
/// </summary>
|
||||
[Collection(Snap7ServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Device", "S7_1500")]
|
||||
public sealed class S7_1500BlockCoalescingTests(Snap7ServerFixture sim)
|
||||
{
|
||||
[Fact]
|
||||
public async Task Driver_coalesces_contiguous_DBWs_into_single_byte_range_read()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Build a 50-tag config covering DB1.DBW0, DBW2, DBW4, ..., DBW98.
|
||||
// Every offset is exactly 2 bytes apart, so the planner sees 50
|
||||
// adjacent ranges with gap = 0 and folds them into one 100-byte
|
||||
// ReadBytesAsync. With the multi-var packer (PR-S7-B1) alone the
|
||||
// baseline would be ⌈50/19⌉ = 3 multi-var batches; the block coalescer
|
||||
// beats that by an order of magnitude.
|
||||
var tags = new List<S7TagDefinition>(50);
|
||||
for (var i = 0; i < 50; i++)
|
||||
tags.Add(new S7TagDefinition($"BulkDBW{i:D2}", $"DB1.DBW{i * 2}", S7DataType.UInt16));
|
||||
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
CpuType = S7NetCpuType.S71500,
|
||||
Rack = 0,
|
||||
Slot = 0,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
Tags = tags,
|
||||
};
|
||||
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-block-coalesce");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var blockReadsBefore = drv.TotalBlockReads;
|
||||
var multiVarBefore = drv.TotalMultiVarBatches;
|
||||
|
||||
var snapshots = await drv.ReadAsync(
|
||||
tags.Select(t => t.Name).ToList(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.Count.ShouldBe(50);
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == 0u, "every coalesced read must surface a Good status");
|
||||
|
||||
// Headline assertion: exactly one byte-range PDU was issued for the
|
||||
// entire 50-tag fan-in. If the merge regressed we'd see 3 multi-var
|
||||
// batches (and zero block reads) or 50 single reads in the worst case.
|
||||
var blockReadsDelta = drv.TotalBlockReads - blockReadsBefore;
|
||||
var multiVarDelta = drv.TotalMultiVarBatches - multiVarBefore;
|
||||
|
||||
blockReadsDelta.ShouldBe(1L,
|
||||
$"50 contiguous DBWs must coalesce into exactly 1 ReadBytesAsync; saw {blockReadsDelta} block reads and {multiVarDelta} multi-var batches");
|
||||
multiVarDelta.ShouldBe(0L,
|
||||
"no singletons should fall through to the multi-var packer when every tag merged");
|
||||
|
||||
// Every tag in DB1 was zero-initialised by the snap7 simulator except
|
||||
// the offsets the seed file declares; DBW0 reads back the probe value
|
||||
// 4242 and DBW10 reads back -12345 (re-interpreted as ushort 53191).
|
||||
// Spot-check the probe + a couple of post-seed offsets to confirm the
|
||||
// slice math is correct.
|
||||
Convert.ToInt32(snapshots[0].Value).ShouldBe(4242, "DB1.DBW0 carries the seeded 4242 probe value");
|
||||
Convert.ToInt32(snapshots[5].Value).ShouldBe(unchecked((ushort)(short)-12345),
|
||||
"DB1.DBW10 carries the seeded -12345 (read as UInt16 wire pattern)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Driver_skips_coalescing_when_gap_threshold_is_zero_and_layout_is_sparse()
|
||||
{
|
||||
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
|
||||
|
||||
// Sparse layout: 3 DBWs with a 100-byte gap between each. Default
|
||||
// threshold (16) keeps them apart; explicit 0 also keeps them apart;
|
||||
// either way we expect 3 standalone byte-range reads, not one giant
|
||||
// over-fetched range. Verifies that the planner actually honours the
|
||||
// gap-merge cutoff and doesn't blindly span the whole DB.
|
||||
var tags = new[]
|
||||
{
|
||||
new S7TagDefinition("Sparse_0", "DB1.DBW0", S7DataType.UInt16),
|
||||
new S7TagDefinition("Sparse_100", "DB1.DBW100", S7DataType.UInt16),
|
||||
new S7TagDefinition("Sparse_200", "DB1.DBW200", S7DataType.UInt16),
|
||||
};
|
||||
|
||||
var options = new S7DriverOptions
|
||||
{
|
||||
Host = sim.Host,
|
||||
Port = sim.Port,
|
||||
CpuType = S7NetCpuType.S71500,
|
||||
Rack = 0,
|
||||
Slot = 0,
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new S7ProbeOptions { Enabled = false },
|
||||
BlockCoalescingGapBytes = 0, // strict: only adjacent ranges merge
|
||||
Tags = tags,
|
||||
};
|
||||
|
||||
await using var drv = new S7Driver(options, driverInstanceId: "s7-block-coalesce-sparse");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
var blockReadsBefore = drv.TotalBlockReads;
|
||||
var multiVarBefore = drv.TotalMultiVarBatches;
|
||||
|
||||
var snapshots = await drv.ReadAsync(
|
||||
tags.Select(t => t.Name).ToList(),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.ShouldAllBe(s => s.StatusCode == 0u);
|
||||
|
||||
// Each tag is a singleton range — the planner emits 3 single-tag
|
||||
// ranges and the driver routes them through the multi-var packer
|
||||
// rather than one ReadBytesAsync per tag. Result: 0 block reads, 1
|
||||
// multi-var batch covering all 3 tags.
|
||||
(drv.TotalBlockReads - blockReadsBefore).ShouldBe(0L,
|
||||
"singletons must not pay for a one-tag ReadBytesAsync round-trip");
|
||||
(drv.TotalMultiVarBatches - multiVarBefore).ShouldBe(1L,
|
||||
"3 singleton tags should pack into a single multi-var batch");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using S7NetCpuType = global::S7.Net.CpuType;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
@@ -14,6 +15,8 @@ public sealed class S7AddressParserTests
|
||||
[InlineData("DB1.DBB0", 1, S7Size.Byte, 0, 0)]
|
||||
[InlineData("DB1.DBW0", 1, S7Size.Word, 0, 0)]
|
||||
[InlineData("DB1.DBD4", 1, S7Size.DWord, 4, 0)]
|
||||
[InlineData("DB1.DBLD0", 1, S7Size.LWord, 0, 0)] // 64-bit long DWord
|
||||
[InlineData("DB1.DBL8", 1, S7Size.LWord, 8, 0)] // 64-bit alt suffix (LReal)
|
||||
[InlineData("DB10.DBW100", 10, S7Size.Word, 100, 0)]
|
||||
[InlineData("DB1.DBX15.3", 1, S7Size.Bit, 15, 3)]
|
||||
public void Parse_data_block_addresses(string input, int db, S7Size size, int byteOff, int bitOff)
|
||||
@@ -53,6 +56,9 @@ public sealed class S7AddressParserTests
|
||||
[InlineData("QW0", S7Area.Output, S7Size.Word, 0, 0)]
|
||||
[InlineData("Q0.0", S7Area.Output, S7Size.Bit, 0, 0)]
|
||||
[InlineData("QD4", S7Area.Output, S7Size.DWord, 4, 0)]
|
||||
[InlineData("MLD0", S7Area.Memory, S7Size.LWord, 0, 0)] // 64-bit Merker
|
||||
[InlineData("ILD8", S7Area.Input, S7Size.LWord, 8, 0)]
|
||||
[InlineData("QLD16", S7Area.Output, S7Size.LWord, 16, 0)]
|
||||
public void Parse_MIQ_addresses(string input, S7Area area, S7Size size, int byteOff, int bitOff)
|
||||
{
|
||||
var r = S7AddressParser.Parse(input);
|
||||
@@ -116,4 +122,45 @@ public sealed class S7AddressParserTests
|
||||
r.DbNumber.ShouldBe(1);
|
||||
r.Size.ShouldBe(S7Size.Word);
|
||||
}
|
||||
|
||||
// --- V-memory (S7-200 / S7-200 Smart / LOGO!) ---
|
||||
|
||||
[Theory]
|
||||
[InlineData("VB0", S7Size.Byte, 0, 0)]
|
||||
[InlineData("VW0", S7Size.Word, 0, 0)]
|
||||
[InlineData("VD4", S7Size.DWord, 4, 0)]
|
||||
[InlineData("V0.0", S7Size.Bit, 0, 0)]
|
||||
[InlineData("V10.7", S7Size.Bit, 10, 7)]
|
||||
public void Parse_V_memory_maps_to_DB1_for_S7200(string input, S7Size size, int byteOff, int bitOff)
|
||||
{
|
||||
var r = S7AddressParser.Parse(input, S7NetCpuType.S7200);
|
||||
r.Area.ShouldBe(S7Area.DataBlock);
|
||||
r.DbNumber.ShouldBe(1);
|
||||
r.Size.ShouldBe(size);
|
||||
r.ByteOffset.ShouldBe(byteOff);
|
||||
r.BitOffset.ShouldBe(bitOff);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7NetCpuType.S7200Smart)]
|
||||
[InlineData(S7NetCpuType.Logo0BA8)]
|
||||
public void Parse_V_memory_maps_to_DB1_for_S7200Smart_and_LOGO(S7NetCpuType cpu)
|
||||
{
|
||||
var r = S7AddressParser.Parse("VW0", cpu);
|
||||
r.Area.ShouldBe(S7Area.DataBlock);
|
||||
r.DbNumber.ShouldBe(1);
|
||||
r.Size.ShouldBe(S7Size.Word);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7NetCpuType.S71500)]
|
||||
[InlineData(S7NetCpuType.S71200)]
|
||||
[InlineData(S7NetCpuType.S7300)]
|
||||
[InlineData(S7NetCpuType.S7400)]
|
||||
public void Parse_V_memory_rejected_on_modern_families(S7NetCpuType cpu)
|
||||
=> Should.Throw<FormatException>(() => S7AddressParser.Parse("VW0", cpu));
|
||||
|
||||
[Fact]
|
||||
public void Parse_V_memory_rejected_when_no_CpuType_supplied()
|
||||
=> Should.Throw<FormatException>(() => S7AddressParser.Parse("VW0"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the block-read coalescing planner (PR-S7-B2). Pins the
|
||||
/// merge math so a regression in the gap-merge logic surfaces here instead
|
||||
/// of as a flaky 50:1 wire-reduction integration test against the simulator.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7BlockCoalescingPlannerTests
|
||||
{
|
||||
private static S7BlockCoalescingPlanner.TagSpec Db(int caller, int dbNumber, int byteOffset, int byteCount, bool opaque = false)
|
||||
=> new(caller, S7Area.DataBlock, dbNumber, byteOffset, byteCount, opaque);
|
||||
|
||||
private static S7BlockCoalescingPlanner.TagSpec M(int caller, int byteOffset, int byteCount)
|
||||
=> new(caller, S7Area.Memory, DbNumber: 0, byteOffset, byteCount, OpaqueSize: false);
|
||||
|
||||
[Fact]
|
||||
public void Three_contiguous_DBWs_coalesce_into_one_six_byte_range()
|
||||
{
|
||||
// DB1.DBW0 (2 B) + DB1.DBW2 (2 B) + DB1.DBW4 (2 B) → one 6-byte range
|
||||
// covering offsets 0..6 within DB1. The headline coalescing claim.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(caller: 0, dbNumber: 1, byteOffset: 0, byteCount: 2),
|
||||
Db(caller: 1, dbNumber: 1, byteOffset: 2, byteCount: 2),
|
||||
Db(caller: 2, dbNumber: 1, byteOffset: 4, byteCount: 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(1);
|
||||
ranges[0].Area.ShouldBe(S7Area.DataBlock);
|
||||
ranges[0].DbNumber.ShouldBe(1);
|
||||
ranges[0].StartByte.ShouldBe(0);
|
||||
ranges[0].ByteCount.ShouldBe(6);
|
||||
ranges[0].Tags.Count.ShouldBe(3);
|
||||
ranges[0].Tags.Select(t => t.CallerIndex).ShouldBe(new[] { 0, 1, 2 });
|
||||
ranges[0].Tags.Select(t => t.OffsetInBlock).ShouldBe(new[] { 0, 2, 4 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Far_apart_tags_do_not_merge_when_gap_exceeds_threshold()
|
||||
{
|
||||
// DB1.DBW0 + DB1.DBW100 → gap of 98 bytes, way above the default 16-byte
|
||||
// threshold. Two standalone ranges so neither over-fetches into dead space.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 1, 100, 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(2);
|
||||
ranges.OrderBy(r => r.StartByte).Select(r => r.StartByte).ShouldBe(new[] { 0, 100 });
|
||||
ranges.ShouldAllBe(r => r.ByteCount == 2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_within_default_gap_threshold_merge_into_one_range()
|
||||
{
|
||||
// DBW0 + DBW10 → gap = 8 bytes (DBW0 ends at 2, DBW10 starts at 10).
|
||||
// 8 ≤ 16 default threshold → merge into one 12-byte range starting at 0.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 1, 10, 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(1);
|
||||
ranges[0].StartByte.ShouldBe(0);
|
||||
ranges[0].ByteCount.ShouldBe(12);
|
||||
ranges[0].Tags.Select(t => t.OffsetInBlock).ShouldBe(new[] { 0, 10 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_areas_never_merge_even_when_offsets_align()
|
||||
{
|
||||
// DB1.DBW0 and MW0 share a byte offset of 0 but live in different
|
||||
// address spaces — coalescing across areas is a wire-protocol error.
|
||||
var specs = new S7BlockCoalescingPlanner.TagSpec[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
M(1, 0, 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(2);
|
||||
ranges.Any(r => r.Area == S7Area.DataBlock && r.DbNumber == 1).ShouldBeTrue();
|
||||
ranges.Any(r => r.Area == S7Area.Memory).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Different_DB_numbers_never_merge()
|
||||
{
|
||||
// DB1.DBW0 and DB2.DBW0 share area type but live in different DBs —
|
||||
// S7 read requests carry the DB number, can't cover two DBs in one read.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 2, 0, 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(2);
|
||||
ranges.Select(r => r.DbNumber).OrderBy(n => n).ShouldBe(new[] { 1, 2 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Opaque_tag_in_middle_of_run_splits_into_three_ranges()
|
||||
{
|
||||
// Sequence: DBW0, STRING@DB1.4 (opaque), DBW10. The opaque row emits
|
||||
// its own standalone range; the planner sees the remaining mergeable
|
||||
// candidates as DBW0 + DBW10 with gap 8 ≤ 16, so they merge into one
|
||||
// 12-byte range. Total 2 ranges (DBW0/DBW10 merged + opaque STRING).
|
||||
// Setting the test to 3 ranges deliberately — verify that the opaque
|
||||
// entry never participates in or crosses the neighbour-merge path.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 1, 4, 256, opaque: true), // STRING-shaped, variable header
|
||||
Db(2, 1, 270, 2), // far enough to not merge with DBW0
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(3);
|
||||
ranges.Count(r => r.Tags.Count == 1).ShouldBe(3);
|
||||
ranges.Single(r => r.StartByte == 4 && r.Tags[0].CallerIndex == 1).ByteCount.ShouldBe(256);
|
||||
ranges.Single(r => r.StartByte == 0).Tags[0].CallerIndex.ShouldBe(0);
|
||||
ranges.Single(r => r.StartByte == 270).Tags[0].CallerIndex.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Opaque_tag_does_not_extend_a_neighbour_block()
|
||||
{
|
||||
// DBW0, DBW2, then opaque STRING at byte 4 — without the opaque opt-out
|
||||
// the planner would happily fold them all into one read. The opaque
|
||||
// marker must keep the STRING out of the merged range.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 1, 2, 2),
|
||||
Db(2, 1, 4, 256, opaque: true),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(2);
|
||||
var merged = ranges.Single(r => r.Tags.Count == 2);
|
||||
merged.ByteCount.ShouldBe(4); // DBW0 + DBW2 only — STRING is its own range
|
||||
var opaque = ranges.Single(r => r.Tags.Count == 1);
|
||||
opaque.StartByte.ShouldBe(4);
|
||||
opaque.ByteCount.ShouldBe(256);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configurable_gap_threshold_can_merge_a_wider_gap()
|
||||
{
|
||||
// gap = 20 bytes between DBW0 (ends @2) and DBD22 (starts @22).
|
||||
// Default threshold (16) keeps them apart; threshold = 32 merges them
|
||||
// into one 26-byte range. Operator-tunable knob.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 1, 22, 4),
|
||||
};
|
||||
|
||||
var defaultPlan = S7BlockCoalescingPlanner.Plan(specs, gapMergeBytes: 16);
|
||||
defaultPlan.Count.ShouldBe(2);
|
||||
|
||||
var widenedPlan = S7BlockCoalescingPlanner.Plan(specs, gapMergeBytes: 32);
|
||||
widenedPlan.Count.ShouldBe(1);
|
||||
widenedPlan[0].StartByte.ShouldBe(0);
|
||||
widenedPlan[0].ByteCount.ShouldBe(26);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Zero_gap_threshold_only_merges_strictly_adjacent_ranges()
|
||||
{
|
||||
// DBW0 (0..2) + DBW2 (2..4) are adjacent (gap = 0); DBW6 has gap = 2.
|
||||
// Threshold 0 → DBW0+DBW2 merge but DBW6 stays standalone.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 2),
|
||||
Db(1, 1, 2, 2),
|
||||
Db(2, 1, 6, 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs, gapMergeBytes: 0);
|
||||
|
||||
ranges.Count.ShouldBe(2);
|
||||
ranges.Single(r => r.Tags.Count == 2).ByteCount.ShouldBe(4);
|
||||
ranges.Single(r => r.Tags.Count == 1).StartByte.ShouldBe(6);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Empty_input_returns_empty_plan()
|
||||
{
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(System.Array.Empty<S7BlockCoalescingPlanner.TagSpec>());
|
||||
ranges.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Negative_gap_threshold_is_rejected()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
S7BlockCoalescingPlanner.Plan([Db(0, 1, 0, 2)], gapMergeBytes: -1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tags_with_overlapping_ranges_still_coalesce_correctly()
|
||||
{
|
||||
// DBD0 (0..4) + DBW2 (2..4): the second tag is entirely inside the
|
||||
// first's footprint. Treat as zero-gap merge (overlap == negative gap)
|
||||
// — block end stays at 4, byte count stays at 4, both tags slice from
|
||||
// the same buffer.
|
||||
var specs = new[]
|
||||
{
|
||||
Db(0, 1, 0, 4),
|
||||
Db(1, 1, 2, 2),
|
||||
};
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(1);
|
||||
ranges[0].StartByte.ShouldBe(0);
|
||||
ranges[0].ByteCount.ShouldBe(4);
|
||||
ranges[0].Tags.Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fifty_contiguous_DBWs_coalesce_into_one_hundred_byte_range()
|
||||
{
|
||||
// The integration-test workload at the unit level: 50 DBW reads at
|
||||
// offsets 0,2,4,...,98 must coalesce into one read covering 100 bytes.
|
||||
var specs = Enumerable.Range(0, 50)
|
||||
.Select(i => Db(caller: i, dbNumber: 1, byteOffset: i * 2, byteCount: 2))
|
||||
.ToArray();
|
||||
|
||||
var ranges = S7BlockCoalescingPlanner.Plan(specs);
|
||||
|
||||
ranges.Count.ShouldBe(1);
|
||||
ranges[0].StartByte.ShouldBe(0);
|
||||
ranges[0].ByteCount.ShouldBe(100);
|
||||
ranges[0].Tags.Count.ShouldBe(50);
|
||||
}
|
||||
|
||||
// ---- IsOpaqueSize classifier ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7DataType.String, true)]
|
||||
[InlineData(S7DataType.WString, true)]
|
||||
[InlineData(S7DataType.Char, true)]
|
||||
[InlineData(S7DataType.WChar, true)]
|
||||
[InlineData(S7DataType.Dtl, true)]
|
||||
[InlineData(S7DataType.DateAndTime, true)]
|
||||
[InlineData(S7DataType.S5Time, true)]
|
||||
[InlineData(S7DataType.Time, true)]
|
||||
[InlineData(S7DataType.TimeOfDay, true)]
|
||||
[InlineData(S7DataType.Date, true)]
|
||||
[InlineData(S7DataType.Bool, false)]
|
||||
[InlineData(S7DataType.Byte, false)]
|
||||
[InlineData(S7DataType.Int16, false)]
|
||||
[InlineData(S7DataType.UInt16, false)]
|
||||
[InlineData(S7DataType.Int32, false)]
|
||||
[InlineData(S7DataType.UInt32, false)]
|
||||
[InlineData(S7DataType.Float32, false)]
|
||||
[InlineData(S7DataType.Float64, false)]
|
||||
public void IsOpaqueSize_flags_string_and_structured_timestamp_types(S7DataType type, bool expected)
|
||||
{
|
||||
var tag = new S7TagDefinition("t", "DB1.DBW0", type);
|
||||
S7BlockCoalescingPlanner.IsOpaqueSize(tag).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsOpaqueSize_flags_arrays_regardless_of_element_type()
|
||||
{
|
||||
// Even Int16 — which is otherwise mergeable as a scalar — turns opaque
|
||||
// when ElementCount > 1 because the per-tag width is N × 2 bytes.
|
||||
var arrayTag = new S7TagDefinition("a", "DB1.DBW0", S7DataType.Int16, ElementCount: 4);
|
||||
S7BlockCoalescingPlanner.IsOpaqueSize(arrayTag).ShouldBeTrue();
|
||||
|
||||
var scalarTag = new S7TagDefinition("s", "DB1.DBW0", S7DataType.Int16);
|
||||
S7BlockCoalescingPlanner.IsOpaqueSize(scalarTag).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(S7Size.Bit, 1)]
|
||||
[InlineData(S7Size.Byte, 1)]
|
||||
[InlineData(S7Size.Word, 2)]
|
||||
[InlineData(S7Size.DWord, 4)]
|
||||
[InlineData(S7Size.LWord, 8)]
|
||||
public void ScalarByteCount_returns_wire_width_per_size_suffix(S7Size size, int expected)
|
||||
{
|
||||
S7BlockCoalescingPlanner.ScalarByteCount(size).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
336
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DateTimeCodecTests.cs
Normal file
336
tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DateTimeCodecTests.cs
Normal file
@@ -0,0 +1,336 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Golden-byte unit tests for <see cref="S7DateTimeCodec"/>: DTL / DATE_AND_TIME /
|
||||
/// S5TIME / TIME / TOD / DATE encode + decode round-trips, plus the
|
||||
/// uninitialized-PLC-buffer rejection paths. These tests don't touch S7.Net — the
|
||||
/// codec operates on raw byte spans, same testing pattern as
|
||||
/// <see cref="S7StringCodecTests"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class S7DateTimeCodecTests
|
||||
{
|
||||
// -------- DTL (12 bytes) --------
|
||||
|
||||
[Fact]
|
||||
public void EncodeDtl_emits_be_year_then_components_then_be_nanoseconds()
|
||||
{
|
||||
// 2024-01-15 12:34:56.000 → year=0x07E8, mon=01, day=0F, dow=Mon=2,
|
||||
// hour=0x0C, min=0x22, sec=0x38, nanos=0x00000000
|
||||
var dt = new DateTime(2024, 1, 15, 12, 34, 56, DateTimeKind.Unspecified);
|
||||
var bytes = S7DateTimeCodec.EncodeDtl(dt);
|
||||
|
||||
bytes.Length.ShouldBe(12);
|
||||
bytes[0].ShouldBe<byte>(0x07);
|
||||
bytes[1].ShouldBe<byte>(0xE8);
|
||||
bytes[2].ShouldBe<byte>(0x01);
|
||||
bytes[3].ShouldBe<byte>(0x0F);
|
||||
// 2024-01-15 was a Monday (.NET DayOfWeek=Monday=1); S7 dow = 1+1 = 2.
|
||||
bytes[4].ShouldBe<byte>(0x02);
|
||||
bytes[5].ShouldBe<byte>(0x0C);
|
||||
bytes[6].ShouldBe<byte>(0x22);
|
||||
bytes[7].ShouldBe<byte>(0x38);
|
||||
bytes[8].ShouldBe<byte>(0x00);
|
||||
bytes[9].ShouldBe<byte>(0x00);
|
||||
bytes[10].ShouldBe<byte>(0x00);
|
||||
bytes[11].ShouldBe<byte>(0x00);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeDtl_round_trips_encode_with_nanosecond_precision()
|
||||
{
|
||||
// 250 ms = 250_000_000 ns = 0x0EE6B280
|
||||
var dt = new DateTime(2024, 1, 15, 12, 34, 56, 250, DateTimeKind.Unspecified);
|
||||
var bytes = S7DateTimeCodec.EncodeDtl(dt);
|
||||
var decoded = S7DateTimeCodec.DecodeDtl(bytes);
|
||||
decoded.ShouldBe(dt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeDtl_rejects_all_zero_uninitialized_buffer()
|
||||
{
|
||||
// Brand-new DB — PLC hasn't written a real value yet. Must surface as a hard
|
||||
// error, not as year-0001 garbage.
|
||||
var buf = new byte[12];
|
||||
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(buf));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeDtl_rejects_out_of_range_components()
|
||||
{
|
||||
// Year 1969 → out of S7 DTL spec range (1970..2554).
|
||||
var buf = new byte[] { 0x07, 0xB1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0 };
|
||||
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(buf));
|
||||
|
||||
// Month 13 — invalid.
|
||||
var buf2 = new byte[] { 0x07, 0xE8, 13, 1, 1, 0, 0, 0, 0, 0, 0, 0 };
|
||||
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(buf2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeDtl_rejects_wrong_buffer_length()
|
||||
{
|
||||
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDtl(new byte[11]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeDtl_rejects_year_outside_spec()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
S7DateTimeCodec.EncodeDtl(new DateTime(1969, 1, 1)));
|
||||
}
|
||||
|
||||
// -------- DATE_AND_TIME (DT, 8 bytes BCD) --------
|
||||
|
||||
[Fact]
|
||||
public void EncodeDt_emits_bcd_components()
|
||||
{
|
||||
// 2024-01-15 12:34:56.789, dow Monday → S7 dow = 2.
|
||||
// BCD: yy=24→0x24, mon=01→0x01, day=15→0x15, hh=12→0x12, mm=34→0x34, ss=56→0x56
|
||||
// ms=789 → bytes[6]=0x78, bytes[7] high nibble=0x9, low nibble=dow=2 → 0x92.
|
||||
var dt = new DateTime(2024, 1, 15, 12, 34, 56, 789, DateTimeKind.Unspecified);
|
||||
var bytes = S7DateTimeCodec.EncodeDt(dt);
|
||||
|
||||
bytes.Length.ShouldBe(8);
|
||||
bytes[0].ShouldBe<byte>(0x24);
|
||||
bytes[1].ShouldBe<byte>(0x01);
|
||||
bytes[2].ShouldBe<byte>(0x15);
|
||||
bytes[3].ShouldBe<byte>(0x12);
|
||||
bytes[4].ShouldBe<byte>(0x34);
|
||||
bytes[5].ShouldBe<byte>(0x56);
|
||||
bytes[6].ShouldBe<byte>(0x78);
|
||||
bytes[7].ShouldBe<byte>(0x92);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeDt_round_trips_post_2000()
|
||||
{
|
||||
var dt = new DateTime(2024, 1, 15, 12, 34, 56, 789, DateTimeKind.Unspecified);
|
||||
var bytes = S7DateTimeCodec.EncodeDt(dt);
|
||||
S7DateTimeCodec.DecodeDt(bytes).ShouldBe(dt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeDt_round_trips_pre_2000_using_90_to_99_year_window()
|
||||
{
|
||||
// 1995-06-30 — yy=95 → year window says 1995.
|
||||
var dt = new DateTime(1995, 6, 30, 0, 0, 0, DateTimeKind.Unspecified);
|
||||
var bytes = S7DateTimeCodec.EncodeDt(dt);
|
||||
bytes[0].ShouldBe<byte>(0x95);
|
||||
S7DateTimeCodec.DecodeDt(bytes).ShouldBe(dt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeDt_rejects_all_zero_uninitialized_buffer()
|
||||
{
|
||||
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDt(new byte[8]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeDt_rejects_invalid_bcd_nibble()
|
||||
{
|
||||
// Month 0x1A — high nibble OK but low nibble 0xA is not a decimal digit.
|
||||
var buf = new byte[] { 0x24, 0x1A, 0x15, 0x12, 0x34, 0x56, 0x00, 0x02 };
|
||||
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDt(buf));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeDt_rejects_year_outside_1990_to_2089_window()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
S7DateTimeCodec.EncodeDt(new DateTime(1989, 12, 31)));
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
S7DateTimeCodec.EncodeDt(new DateTime(2090, 1, 1)));
|
||||
}
|
||||
|
||||
// -------- S5TIME (16 bits BCD) --------
|
||||
|
||||
[Fact]
|
||||
public void EncodeS5Time_one_second_uses_10ms_timebase_with_count_100()
|
||||
{
|
||||
// T#1S = 1000 ms = 100 × 10 ms (timebase 0). Layout: 0000 00BB BBBB BBBB
|
||||
// tb=00, count=100=BCD 0x100 → byte0=0x01, byte1=0x00.
|
||||
var bytes = S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(1));
|
||||
bytes.Length.ShouldBe(2);
|
||||
bytes[0].ShouldBe<byte>(0x01);
|
||||
bytes[1].ShouldBe<byte>(0x00);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeS5Time_picks_10s_timebase_for_long_durations()
|
||||
{
|
||||
// T#9990S = 9990 s = 999 × 10 s (timebase 11). count=999 BCD 0x999.
|
||||
// byte0 = (3 << 4) | 9 = 0x39, byte1 = 0x99.
|
||||
var bytes = S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(9990));
|
||||
bytes[0].ShouldBe<byte>(0x39);
|
||||
bytes[1].ShouldBe<byte>(0x99);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeS5Time_round_trips_each_timebase()
|
||||
{
|
||||
foreach (var ts in new[]
|
||||
{
|
||||
TimeSpan.FromMilliseconds(10), // tb=00 (10 ms)
|
||||
TimeSpan.FromMilliseconds(500), // tb=00 (10 ms × 50)
|
||||
TimeSpan.FromSeconds(1), // tb=00 (10 ms × 100)
|
||||
TimeSpan.FromSeconds(60), // tb=01 (100 ms × 600)? No — 60s/100ms=600 > 999, picks 1s.
|
||||
TimeSpan.FromMinutes(10), // tb=10 (1 s × 600)
|
||||
TimeSpan.FromMinutes(30), // tb=11 (10 s × 180)
|
||||
})
|
||||
{
|
||||
var bytes = S7DateTimeCodec.EncodeS5Time(ts);
|
||||
S7DateTimeCodec.DecodeS5Time(bytes).ShouldBe(ts);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeS5Time_rejects_negative_or_too_large()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(-1)));
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
S7DateTimeCodec.EncodeS5Time(TimeSpan.FromSeconds(10_000)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeS5Time_rejects_durations_not_representable_in_any_timebase()
|
||||
{
|
||||
// 7 ms — no timebase divides cleanly, would lose precision.
|
||||
Should.Throw<ArgumentException>(() =>
|
||||
S7DateTimeCodec.EncodeS5Time(TimeSpan.FromMilliseconds(7)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeS5Time_rejects_invalid_bcd()
|
||||
{
|
||||
// Hi nibble of byte0 has timebase=00 but digit nibble 0xA — illegal.
|
||||
var buf = new byte[] { 0x0A, 0x00 };
|
||||
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeS5Time(buf));
|
||||
}
|
||||
|
||||
// -------- TIME (Int32 ms BE, signed) --------
|
||||
|
||||
[Fact]
|
||||
public void EncodeTime_emits_signed_int32_be_milliseconds()
|
||||
{
|
||||
// T#1S = 1000 ms = 0x000003E8.
|
||||
var bytes = S7DateTimeCodec.EncodeTime(TimeSpan.FromSeconds(1));
|
||||
bytes.ShouldBe(new byte[] { 0x00, 0x00, 0x03, 0xE8 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeTime_supports_negative_durations()
|
||||
{
|
||||
// -1000 ms = 0xFFFFFC18 (two's complement Int32).
|
||||
var bytes = S7DateTimeCodec.EncodeTime(TimeSpan.FromSeconds(-1));
|
||||
bytes.ShouldBe(new byte[] { 0xFF, 0xFF, 0xFC, 0x18 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeTime_round_trips_positive_and_negative()
|
||||
{
|
||||
foreach (var ts in new[]
|
||||
{
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromMilliseconds(1),
|
||||
TimeSpan.FromHours(24),
|
||||
TimeSpan.FromMilliseconds(-12345),
|
||||
})
|
||||
{
|
||||
S7DateTimeCodec.DecodeTime(S7DateTimeCodec.EncodeTime(ts)).ShouldBe(ts);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeTime_rejects_overflow()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
S7DateTimeCodec.EncodeTime(TimeSpan.FromMilliseconds((long)int.MaxValue + 1)));
|
||||
}
|
||||
|
||||
// -------- TOD (UInt32 ms BE, 0..86399999) --------
|
||||
|
||||
[Fact]
|
||||
public void EncodeTod_emits_unsigned_int32_be_milliseconds()
|
||||
{
|
||||
// 12:00:00.000 = 12 × 3_600_000 = 43_200_000 ms = 0x0293_2E00.
|
||||
var bytes = S7DateTimeCodec.EncodeTod(new TimeSpan(12, 0, 0));
|
||||
bytes.ShouldBe(new byte[] { 0x02, 0x93, 0x2E, 0x00 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeTod_round_trips_midnight_and_max()
|
||||
{
|
||||
S7DateTimeCodec.DecodeTod(S7DateTimeCodec.EncodeTod(TimeSpan.Zero)).ShouldBe(TimeSpan.Zero);
|
||||
|
||||
// 23:59:59.999 — last valid TOD.
|
||||
var max = new TimeSpan(0, 23, 59, 59, 999);
|
||||
S7DateTimeCodec.DecodeTod(S7DateTimeCodec.EncodeTod(max)).ShouldBe(max);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeTod_rejects_value_at_or_above_one_day()
|
||||
{
|
||||
// 86_400_000 ms = 0x0526_5C00 — exactly 24 h, must reject.
|
||||
var buf = new byte[] { 0x05, 0x26, 0x5C, 0x00 };
|
||||
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeTod(buf));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeTod_rejects_negative_and_overflow()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
S7DateTimeCodec.EncodeTod(TimeSpan.FromMilliseconds(-1)));
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
S7DateTimeCodec.EncodeTod(TimeSpan.FromHours(24)));
|
||||
}
|
||||
|
||||
// -------- DATE (UInt16 BE, days since 1990-01-01) --------
|
||||
|
||||
[Fact]
|
||||
public void EncodeDate_emits_be_uint16_days_since_epoch()
|
||||
{
|
||||
// 1990-01-01 → 0 days → 0x0000.
|
||||
S7DateTimeCodec.EncodeDate(new DateTime(1990, 1, 1)).ShouldBe(new byte[] { 0x00, 0x00 });
|
||||
|
||||
// 1990-01-02 → 1 day → 0x0001.
|
||||
S7DateTimeCodec.EncodeDate(new DateTime(1990, 1, 2)).ShouldBe(new byte[] { 0x00, 0x01 });
|
||||
|
||||
// 2024-01-15 → 12_432 days = 0x3090.
|
||||
var bytes = S7DateTimeCodec.EncodeDate(new DateTime(2024, 1, 15));
|
||||
bytes.ShouldBe(new byte[] { 0x30, 0x90 });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeDate_round_trips_encode()
|
||||
{
|
||||
foreach (var d in new[]
|
||||
{
|
||||
new DateTime(1990, 1, 1),
|
||||
new DateTime(2000, 2, 29),
|
||||
new DateTime(2024, 1, 15),
|
||||
new DateTime(2099, 12, 31),
|
||||
})
|
||||
{
|
||||
S7DateTimeCodec.DecodeDate(S7DateTimeCodec.EncodeDate(d)).ShouldBe(d);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EncodeDate_rejects_pre_epoch()
|
||||
{
|
||||
Should.Throw<ArgumentOutOfRangeException>(() =>
|
||||
S7DateTimeCodec.EncodeDate(new DateTime(1989, 12, 31)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DecodeDate_rejects_wrong_buffer_length()
|
||||
{
|
||||
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDate(new byte[1]));
|
||||
Should.Throw<InvalidDataException>(() => S7DateTimeCodec.DecodeDate(new byte[3]));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user