Compare commits
14 Commits
phase-3-pr
...
phase-3-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b04a85f86 | ||
| cd8691280a | |||
|
|
77d09bf64e | ||
| 163c821e74 | |||
|
|
eea31dcc4e | ||
| 8a692d4ba8 | |||
|
|
268b12edec | ||
| edce1be742 | |||
|
|
18b3e24710 | ||
| f6a12dafe9 | |||
|
|
058c3dddd3 | ||
| 52791952dd | |||
|
|
860deb8e0d | ||
| f5e7173de3 |
@@ -8,6 +8,7 @@
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.Shared/ZB.MOM.WW.OtOpcUa.Client.Shared.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.CLI/ZB.MOM.WW.OtOpcUa.Client.CLI.csproj"/>
|
||||
<Project Path="src/ZB.MOM.WW.OtOpcUa.Client.UI/ZB.MOM.WW.OtOpcUa.Client.UI.csproj"/>
|
||||
@@ -22,6 +23,7 @@
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E/ZB.MOM.WW.OtOpcUa.Driver.Galaxy.E2E.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests/ZB.MOM.WW.OtOpcUa.Client.Shared.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests/ZB.MOM.WW.OtOpcUa.Client.CLI.Tests.csproj"/>
|
||||
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests/ZB.MOM.WW.OtOpcUa.Client.UI.Tests.csproj"/>
|
||||
|
||||
104
docs/v2/lmx-followups.md
Normal file
104
docs/v2/lmx-followups.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# LMX Galaxy bridge — remaining follow-ups
|
||||
|
||||
State after PR 19: the Galaxy driver is functionally at v1 parity through the
|
||||
`IDriver` abstraction; the OPC UA server runs with LDAP-authenticated
|
||||
Basic256Sha256 endpoints and alarms are observable through
|
||||
`AlarmConditionState.ReportEvent`. The items below are what remains LMX-
|
||||
specific before the stack can fully replace the v1 deployment, in
|
||||
rough priority order.
|
||||
|
||||
## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents`
|
||||
|
||||
**Status**: Host-side IPC shipped (PR 10 + PR 11). Proxy consumer not written.
|
||||
|
||||
PR 10 added `HistoryReadAtTimeRequest/Response` on the IPC wire and
|
||||
`MxAccessGalaxyBackend.HistoryReadAtTimeAsync` delegates to
|
||||
`HistorianDataSource.ReadAtTimeAsync`. PR 11 did the same for events
|
||||
(`HistoryReadEventsRequest/Response` + `GalaxyHistoricalEvent`). The Proxy
|
||||
side (`GalaxyProxyDriver`) doesn't call those yet — `Core.Abstractions.IHistoryProvider`
|
||||
only exposes `ReadRawAsync` + `ReadProcessedAsync`.
|
||||
|
||||
**To do**:
|
||||
- Extend `IHistoryProvider` with `ReadAtTimeAsync(string, DateTime[], …)` and
|
||||
`ReadEventsAsync(string?, DateTime, DateTime, int, …)`.
|
||||
- `GalaxyProxyDriver` calls the new IPC message kinds.
|
||||
- `DriverNodeManager` wires the new capability methods onto `HistoryRead`
|
||||
`AtTime` + `Events` service handlers.
|
||||
- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`,
|
||||
value flows through IPC to the Host's `HistorianDataSource`, back to the client.
|
||||
|
||||
## 2. Write-gating by role — **DONE (PR 26)**
|
||||
|
||||
Landed in PR 26. `WriteAuthzPolicy` in `Server/Security/` maps
|
||||
`SecurityClassification` → required role (`FreeAccess` → no role required,
|
||||
`Operate`/`SecuredWrite` → `WriteOperate`, `Tune` → `WriteTune`,
|
||||
`Configure`/`VerifiedWrite` → `WriteConfigure`, `ViewOnly` → deny regardless).
|
||||
`DriverNodeManager` caches the classification per variable during discovery and
|
||||
checks the session's roles (via `IRoleBearer`) in `OnWriteValue` before calling
|
||||
`IWritable.WriteAsync`. Roles do not cascade — a session with `WriteOperate`
|
||||
can't write a `Tune` attribute unless it also carries `WriteTune`.
|
||||
|
||||
See `feedback_acl_at_server_layer.md` in memory for the architectural directive
|
||||
that authz stays at the server layer and never delegates to driver-specific auth.
|
||||
|
||||
## 3. Admin UI client-cert trust management
|
||||
|
||||
**Status**: Server side auto-accepts untrusted client certs when the
|
||||
`AutoAcceptUntrustedClientCertificates` option is true (dev default).
|
||||
Production deployments want operator-controlled trust via the Admin UI.
|
||||
|
||||
**To do**:
|
||||
- Surface the server's rejected-certificate store in the Admin UI.
|
||||
- Page to move certs between `rejected` / `trusted`.
|
||||
- Flip `AutoAcceptUntrustedClientCertificates` to false once Admin UI is the
|
||||
trust gate.
|
||||
|
||||
## 4. Live-LDAP integration test
|
||||
|
||||
**Status**: PR 19 unit-tested the auth-flow shape; the live bind path is
|
||||
exercised only by the pre-existing `Admin.Tests/LdapLiveBindTests.cs` which
|
||||
uses the same Novell library against a running GLAuth at `localhost:3893`.
|
||||
|
||||
**To do**:
|
||||
- Add `OpcUaServerIntegrationTests.Valid_username_authenticates_against_live_ldap`
|
||||
with the same skip-when-unreachable guard.
|
||||
- Assert `session.Identity` on the server side carries the expected role
|
||||
after bind — requires exposing a test hook or reading identity from a
|
||||
new `IHostConnectivityProbe`-style "whoami" variable in the address space.
|
||||
|
||||
## 5. Full Galaxy live-service smoke test against the merged v2 stack
|
||||
|
||||
**Status**: Individual pieces have live smoke tests (PR 5 MXAccess, PR 13
|
||||
probe manager, PR 14 alarm tracker), but the full loop — OPC UA client →
|
||||
`OtOpcUaServer` → `GalaxyProxyDriver` (in-process) → named-pipe to
|
||||
Galaxy.Host subprocess → live MXAccess runtime → real Galaxy objects — has
|
||||
no single end-to-end smoke test.
|
||||
|
||||
**To do**:
|
||||
- Test that spawns the full topology, discovers a deployed Galaxy object,
|
||||
subscribes to one of its attributes, writes a value back, and asserts the
|
||||
write round-tripped through MXAccess. Skip when ArchestrA isn't running.
|
||||
|
||||
## 6. Second driver instance on the same server
|
||||
|
||||
**Status**: `DriverHost.RegisterAsync` supports multiple drivers; the OPC UA
|
||||
server creates one `DriverNodeManager` per driver and isolates their
|
||||
subtrees under distinct namespace URIs. Not proven with two active
|
||||
`GalaxyProxyDriver` instances pointing at different Galaxies.
|
||||
|
||||
**To do**:
|
||||
- Integration test that registers two driver instances, each with a distinct
|
||||
`DriverInstanceId` + endpoint in its own session, asserts nodes from both
|
||||
appear under the correct subtrees, alarm events land on the correct
|
||||
instance's condition nodes.
|
||||
|
||||
## 7. Host-status per-AppEngine granularity → Admin UI dashboard
|
||||
|
||||
**Status**: PR 13 ships per-platform/per-AppEngine `ScanState` probing; PR 17
|
||||
surfaces the resulting `OnHostStatusChanged` events through OPC UA. Admin
|
||||
UI doesn't render a per-host dashboard yet.
|
||||
|
||||
**To do**:
|
||||
- SignalR hub push of `HostStatusChangedEventArgs` to the Admin UI.
|
||||
- Dashboard page showing each tracked host, current state, last transition
|
||||
time, failure count.
|
||||
103
docs/v2/modbus-test-plan.md
Normal file
103
docs/v2/modbus-test-plan.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Modbus driver — test plan + device-quirk catalog
|
||||
|
||||
The Modbus TCP driver unit tests (PRs 21–24) cover the protocol surface against an
|
||||
in-memory fake transport. They validate the codec, state machine, and function-code
|
||||
routing against a textbook Modbus server. That's necessary but not sufficient: real PLC
|
||||
populations disagree with the spec in small, device-specific ways, and a driver that
|
||||
passes textbook tests can still misbehave against actual equipment.
|
||||
|
||||
This doc is the harness-and-quirks playbook. It's what gets wired up in the
|
||||
`tests/Driver.Modbus.IntegrationTests` project when we ship that (PR 26 candidate).
|
||||
|
||||
## Harness
|
||||
|
||||
**Chosen simulator: ModbusPal** (Java, scriptable). Rationale:
|
||||
- Scriptable enough to mimic device-specific behaviors (non-standard register
|
||||
layouts, custom exception codes, intentional response delays).
|
||||
- Runs locally, no CI dependency. Tests skip when `localhost:502` (or the configured
|
||||
simulator endpoint) isn't reachable.
|
||||
- Free + long-maintained — physical PLC bench is unavailable in most dev
|
||||
environments, and renting cloud PLCs isn't worth the per-test cost.
|
||||
|
||||
**Setup pattern** (not yet codified in a script — will land alongside the integration
|
||||
test project):
|
||||
1. Install ModbusPal, load the per-device `.xmpp` profile from
|
||||
`tests/Driver.Modbus.IntegrationTests/ModbusPal/` (TBD directory).
|
||||
2. Start the simulator listening on `localhost:502` (or override via
|
||||
`MODBUS_SIM_ENDPOINT` env var).
|
||||
3. `dotnet test` the integration project — tests auto-skip when the endpoint is
|
||||
unreachable, so forgetting to start the simulator doesn't wedge CI.
|
||||
|
||||
## Per-device quirk catalog
|
||||
|
||||
### AutomationDirect DL205
|
||||
|
||||
First known target device. Quirks to document and cover with named tests (to be
|
||||
filled in when user validates each behavior in ModbusPal with a DL205 profile):
|
||||
|
||||
- **Word order for 32-bit values**: _pending_ — confirm whether DL205 uses ABCD
|
||||
(Modbus TCP standard) or CDAB (Siemens-style word-swap) for Int32/UInt32/Float32.
|
||||
Test name: `DL205_Float32_word_order_is_CDAB` (or `ABCD`, whichever proves out).
|
||||
- **Register-zero access**: _pending_ — some DL205 configurations reject FC03 at
|
||||
register 0 with exception code 02 (illegal data address). If confirmed, the
|
||||
integration test suite verifies `ModbusProbeOptions.ProbeAddress` default of 0
|
||||
triggers the rejection and operators must override; test name:
|
||||
`DL205_FC03_at_register_0_returns_IllegalDataAddress`.
|
||||
- **Coil addressing base**: _pending_ — DL205 documentation sometimes uses 1-based
|
||||
coil addresses; verify the driver's zero-based addressing matches the physical
|
||||
PLC without an off-by-one adjustment.
|
||||
- **Maximum registers per FC03**: _pending_ — Modbus spec caps at 125; some DL205
|
||||
models enforce a lower limit (e.g., 64). Test name:
|
||||
`DL205_FC03_beyond_max_registers_returns_IllegalDataValue`.
|
||||
- **Response framing under sustained load**: _pending_ — the driver's
|
||||
single-flight semaphore assumes the server pairs requests/responses by
|
||||
transaction id; at least one DL205 firmware revision is reported to drop the
|
||||
TxId under load. If reproduced in ModbusPal we add a retry + log-and-continue
|
||||
path to `ModbusTcpTransport`.
|
||||
- **Exception code on coil write to a protected bit**: _pending_ — some DL205
|
||||
setups protect internal coils; the driver should surface the PLC's exception
|
||||
PDU as `BadNotWritable` rather than `BadInternalError`.
|
||||
|
||||
_User action item_: as each quirk is validated in ModbusPal, replace the _pending_
|
||||
marker with the confirmed behavior and file a named test in the integration suite.
|
||||
|
||||
### Future devices
|
||||
|
||||
One section per device class, same shape as DL205. Quirks that apply across
|
||||
multiple devices (e.g., "all AB PLCs use CDAB") can be noted in the cross-device
|
||||
patterns section below once we have enough data points.
|
||||
|
||||
## Cross-device patterns
|
||||
|
||||
Once multiple device catalogs accumulate, quirks that recur across two or more
|
||||
vendors get promoted into driver defaults or opt-in options:
|
||||
|
||||
- _(empty — filled in as catalogs grow)_
|
||||
|
||||
## Test conventions
|
||||
|
||||
- **One named test per quirk.** `DL205_word_order_is_CDAB_for_Float32` is easier to
|
||||
diagnose on failure than a generic `Float32_roundtrip`. The `DL205_` prefix makes
|
||||
filtering by device class trivial (`--filter "DisplayName~DL205"`).
|
||||
- **Skip with a clear SkipReason.** Follow the pattern from
|
||||
`GalaxyRepositoryLiveSmokeTests`: check reachability in the fixture, capture
|
||||
a `SkipReason` string, and have each test call `Assert.Skip(SkipReason)` when
|
||||
it's set. Don't throw — skipped tests read cleanly in CI logs.
|
||||
- **Use the real `ModbusTcpTransport`.** Integration tests exercise the wire
|
||||
protocol end-to-end. The in-memory `FakeTransport` from the unit test suite is
|
||||
deliberately not used here — its value is speed + determinism, which doesn't
|
||||
help reproduce device-specific issues.
|
||||
- **Don't depend on ModbusPal state between tests.** Each test resets the
|
||||
simulator's register bank or uses a unique address range. Avoid relying on
|
||||
"previous test left value at register 10" setups that flake when tests run in
|
||||
parallel or re-order.
|
||||
|
||||
## Next concrete PRs
|
||||
|
||||
- **PR 26 — Integration test project + DL205 profile scaffold**: creates
|
||||
`tests/Driver.Modbus.IntegrationTests`, imports the ModbusPal profile (or
|
||||
generates it from JSON), adds the fixture with skip-when-unreachable, plus
|
||||
one smoke test that reads a register. No DL205-specific assertions yet — that
|
||||
waits for the user to validate each quirk.
|
||||
- **PR 27+**: one PR per confirmed DL205 quirk, landing the named test + any
|
||||
driver-side adjustment (e.g., retry on dropped TxId) needed to pass it.
|
||||
25
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs
Normal file
25
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/IModbusTransport.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over the Modbus TCP socket. Takes a <c>PDU</c> (function code + data, excluding
|
||||
/// the 7-byte MBAP header) and returns the response PDU — the transport owns transaction-id
|
||||
/// pairing, framing, and socket I/O. Tests supply in-memory fakes.
|
||||
/// </summary>
|
||||
public interface IModbusTransport : IAsyncDisposable
|
||||
{
|
||||
Task ConnectAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Send a Modbus PDU (function code + function-specific data) and read the response PDU.
|
||||
/// Throws <see cref="ModbusException"/> when the server returns an exception PDU
|
||||
/// (function code + 0x80 + exception code).
|
||||
/// </summary>
|
||||
Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed class ModbusException(byte functionCode, byte exceptionCode, string message)
|
||||
: Exception(message)
|
||||
{
|
||||
public byte FunctionCode { get; } = functionCode;
|
||||
public byte ExceptionCode { get; } = exceptionCode;
|
||||
}
|
||||
583
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
Normal file
583
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs
Normal file
@@ -0,0 +1,583 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text.Json;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Modbus TCP implementation of <see cref="IDriver"/> + <see cref="ITagDiscovery"/> +
|
||||
/// <see cref="IReadable"/> + <see cref="IWritable"/>. First native-protocol greenfield
|
||||
/// driver for the v2 stack — validates the driver-agnostic <c>IAddressSpaceBuilder</c> +
|
||||
/// <c>IReadable</c>/<c>IWritable</c> abstractions generalize beyond Galaxy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scope limits: synchronous Read/Write only, no subscriptions (Modbus has no push model;
|
||||
/// subscriptions would need a polling loop over the declared tags — additive PR). Historian
|
||||
/// + alarm capabilities are out of scope (the protocol doesn't express them).
|
||||
/// </remarks>
|
||||
public sealed class ModbusDriver(ModbusDriverOptions options, string driverInstanceId,
|
||||
Func<ModbusDriverOptions, IModbusTransport>? transportFactory = null)
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
|
||||
{
|
||||
// Active polling subscriptions. Each subscription owns a background Task that polls the
|
||||
// tags at its configured interval, diffs against _lastKnownValues, and fires OnDataChange
|
||||
// per changed tag. UnsubscribeAsync cancels the task via the CTS stored on the handle.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
|
||||
private long _nextSubscriptionId;
|
||||
|
||||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
// Single-host probe state — Modbus driver talks to exactly one endpoint so the "hosts"
|
||||
// collection has at most one entry. HostName is the Host:Port string so the Admin UI can
|
||||
// display the PLC endpoint uniformly with Galaxy platforms/engines.
|
||||
private readonly object _probeLock = new();
|
||||
private HostState _hostState = HostState.Unknown;
|
||||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||||
private CancellationTokenSource? _probeCts;
|
||||
private readonly ModbusDriverOptions _options = options;
|
||||
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory =
|
||||
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout));
|
||||
|
||||
private IModbusTransport? _transport;
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
private readonly Dictionary<string, ModbusTagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public string DriverInstanceId => driverInstanceId;
|
||||
public string DriverType => "Modbus";
|
||||
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Initializing, null, null);
|
||||
try
|
||||
{
|
||||
_transport = _transportFactory(_options);
|
||||
await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||
foreach (var t in _options.Tags) _tagsByName[t.Name] = t;
|
||||
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
|
||||
|
||||
// PR 23: kick off the probe loop once the transport is up. Initial state stays
|
||||
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
|
||||
// Running transition before any register round-trip has happened.
|
||||
if (_options.Probe.Enabled)
|
||||
{
|
||||
_probeCts = new CancellationTokenSource();
|
||||
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
await ShutdownAsync(cancellationToken);
|
||||
await InitializeAsync(driverConfigJson, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try { _probeCts?.Cancel(); } catch { }
|
||||
_probeCts?.Dispose();
|
||||
_probeCts = null;
|
||||
|
||||
foreach (var state in _subscriptions.Values)
|
||||
{
|
||||
try { state.Cts.Cancel(); } catch { }
|
||||
state.Cts.Dispose();
|
||||
}
|
||||
_subscriptions.Clear();
|
||||
|
||||
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
|
||||
_transport = null;
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
public DriverHealth GetHealth() => _health;
|
||||
public long GetMemoryFootprint() => 0;
|
||||
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
var folder = builder.Folder("Modbus", "Modbus");
|
||||
foreach (var t in _options.Tags)
|
||||
{
|
||||
folder.Variable(t.Name, t.Name, new DriverAttributeInfo(
|
||||
FullName: t.Name,
|
||||
DriverDataType: MapDataType(t.DataType),
|
||||
IsArray: false,
|
||||
ArrayDim: null,
|
||||
SecurityClass: t.Writable ? SecurityClassification.Operate : SecurityClassification.ViewOnly,
|
||||
IsHistorized: false,
|
||||
IsAlarm: false));
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ---- IReadable ----
|
||||
|
||||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
|
||||
{
|
||||
var transport = RequireTransport();
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
for (var i = 0; i < fullReferences.Count; i++)
|
||||
{
|
||||
if (!_tagsByName.TryGetValue(fullReferences[i], out var tag))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
var value = await ReadOneAsync(transport, tag, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new DataValueSnapshot(value, 0u, now, now);
|
||||
_health = new DriverHealth(DriverState.Healthy, now, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
|
||||
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<object> ReadOneAsync(IModbusTransport transport, ModbusTagDefinition tag, CancellationToken ct)
|
||||
{
|
||||
switch (tag.Region)
|
||||
{
|
||||
case ModbusRegion.Coils:
|
||||
{
|
||||
var pdu = new byte[] { 0x01, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
return (resp[2] & 0x01) == 1;
|
||||
}
|
||||
case ModbusRegion.DiscreteInputs:
|
||||
{
|
||||
var pdu = new byte[] { 0x02, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF), 0x00, 0x01 };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
return (resp[2] & 0x01) == 1;
|
||||
}
|
||||
case ModbusRegion.HoldingRegisters:
|
||||
case ModbusRegion.InputRegisters:
|
||||
{
|
||||
var quantity = RegisterCount(tag);
|
||||
var fc = tag.Region == ModbusRegion.HoldingRegisters ? (byte)0x03 : (byte)0x04;
|
||||
var pdu = new byte[] { fc, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
(byte)(quantity >> 8), (byte)(quantity & 0xFF) };
|
||||
var resp = await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
// resp = [fc][byte-count][data...]
|
||||
var data = new ReadOnlySpan<byte>(resp, 2, resp[1]);
|
||||
return DecodeRegister(data, tag);
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"Unknown region {tag.Region}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IWritable ----
|
||||
|
||||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||||
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
|
||||
{
|
||||
var transport = RequireTransport();
|
||||
var results = new WriteResult[writes.Count];
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
if (!_tagsByName.TryGetValue(w.FullReference, out var tag))
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNodeIdUnknown);
|
||||
continue;
|
||||
}
|
||||
if (!tag.Writable || tag.Region is ModbusRegion.DiscreteInputs or ModbusRegion.InputRegisters)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadNotWritable);
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
|
||||
results[i] = new WriteResult(0u);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
results[i] = new WriteResult(StatusBadInternalError);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task WriteOneAsync(IModbusTransport transport, ModbusTagDefinition tag, object? value, CancellationToken ct)
|
||||
{
|
||||
switch (tag.Region)
|
||||
{
|
||||
case ModbusRegion.Coils:
|
||||
{
|
||||
var on = Convert.ToBoolean(value);
|
||||
var pdu = new byte[] { 0x05, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
on ? (byte)0xFF : (byte)0x00, 0x00 };
|
||||
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
case ModbusRegion.HoldingRegisters:
|
||||
{
|
||||
var bytes = EncodeRegister(value, tag);
|
||||
if (bytes.Length == 2)
|
||||
{
|
||||
var pdu = new byte[] { 0x06, (byte)(tag.Address >> 8), (byte)(tag.Address & 0xFF),
|
||||
bytes[0], bytes[1] };
|
||||
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
// FC 16 (Write Multiple Registers) for 32-bit types
|
||||
var qty = (ushort)(bytes.Length / 2);
|
||||
var pdu = new byte[6 + 1 + bytes.Length];
|
||||
pdu[0] = 0x10;
|
||||
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
|
||||
pdu[3] = (byte)(qty >> 8); pdu[4] = (byte)(qty & 0xFF);
|
||||
pdu[5] = (byte)bytes.Length;
|
||||
Buffer.BlockCopy(bytes, 0, pdu, 6, bytes.Length);
|
||||
await transport.SendAsync(_options.UnitId, pdu, ct).ConfigureAwait(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"Writes not supported for region {tag.Region}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ISubscribable (polling overlay) ----
|
||||
|
||||
public Task<ISubscriptionHandle> SubscribeAsync(
|
||||
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextSubscriptionId);
|
||||
var cts = new CancellationTokenSource();
|
||||
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
|
||||
? TimeSpan.FromMilliseconds(100) // floor — Modbus can't sustain < 100ms polling reliably
|
||||
: publishingInterval;
|
||||
var handle = new ModbusSubscriptionHandle(id);
|
||||
var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
|
||||
_subscriptions[id] = state;
|
||||
_ = Task.Run(() => PollLoopAsync(state, cts.Token), cts.Token);
|
||||
return Task.FromResult<ISubscriptionHandle>(handle);
|
||||
}
|
||||
|
||||
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
|
||||
{
|
||||
if (handle is ModbusSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
|
||||
{
|
||||
state.Cts.Cancel();
|
||||
state.Cts.Dispose();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
|
||||
{
|
||||
// Initial-data push: read every tag once at subscribe time so OPC UA clients see the
|
||||
// current value per Part 4 convention, even if the value never changes thereafter.
|
||||
try { await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* first-read error — polling continues */ }
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try { await Task.Delay(state.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
|
||||
try { await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
catch { /* transient polling error — loop continues, health surface reflects it */ }
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
|
||||
{
|
||||
var snapshots = await ReadAsync(state.TagReferences, ct).ConfigureAwait(false);
|
||||
for (var i = 0; i < state.TagReferences.Count; i++)
|
||||
{
|
||||
var tagRef = state.TagReferences[i];
|
||||
var current = snapshots[i];
|
||||
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
|
||||
|
||||
// Raise on first read (forceRaise) OR when the boxed value differs from last-known.
|
||||
if (forceRaise || !Equals(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
|
||||
{
|
||||
state.LastValues[tagRef] = current;
|
||||
OnDataChange?.Invoke(this, new DataChangeEventArgs(state.Handle, tagRef, current));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record SubscriptionState(
|
||||
ModbusSubscriptionHandle Handle,
|
||||
IReadOnlyList<string> TagReferences,
|
||||
TimeSpan Interval,
|
||||
CancellationTokenSource Cts)
|
||||
{
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
|
||||
= new(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private sealed record ModbusSubscriptionHandle(long Id) : ISubscriptionHandle
|
||||
{
|
||||
public string DiagnosticId => $"modbus-sub-{Id}";
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
||||
{
|
||||
lock (_probeLock)
|
||||
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Host identifier surfaced to <c>IHostConnectivityProbe.GetHostStatuses</c> and the Admin UI.
|
||||
/// Formatted as <c>host:port</c> so multiple Modbus drivers in the same server disambiguate
|
||||
/// by endpoint without needing the driver-instance-id in the Admin dashboard.
|
||||
/// </summary>
|
||||
public string HostName => $"{_options.Host}:{_options.Port}";
|
||||
|
||||
private async Task ProbeLoopAsync(CancellationToken ct)
|
||||
{
|
||||
var transport = _transport; // captured reference; disposal tears the loop down via ct
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
var success = false;
|
||||
try
|
||||
{
|
||||
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
probeCts.CancelAfter(_options.Probe.Timeout);
|
||||
var pdu = new byte[] { 0x03,
|
||||
(byte)(_options.Probe.ProbeAddress >> 8),
|
||||
(byte)(_options.Probe.ProbeAddress & 0xFF), 0x00, 0x01 };
|
||||
_ = await transport!.SendAsync(_options.UnitId, pdu, probeCts.Token).ConfigureAwait(false);
|
||||
success = true;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// transport / timeout / exception PDU — treated as Stopped below
|
||||
}
|
||||
|
||||
TransitionTo(success ? HostState.Running : HostState.Stopped);
|
||||
|
||||
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { return; }
|
||||
}
|
||||
}
|
||||
|
||||
private void TransitionTo(HostState newState)
|
||||
{
|
||||
HostState old;
|
||||
lock (_probeLock)
|
||||
{
|
||||
old = _hostState;
|
||||
if (old == newState) return;
|
||||
_hostState = newState;
|
||||
_hostStateChangedUtc = DateTime.UtcNow;
|
||||
}
|
||||
OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
|
||||
}
|
||||
|
||||
// ---- codec ----
|
||||
|
||||
/// <summary>
|
||||
/// How many 16-bit registers a given tag occupies. Accounts for multi-register logical
|
||||
/// types (Int32/Float32 = 2 regs, Int64/Float64 = 4 regs) and for strings (rounded up
|
||||
/// from 2 chars per register).
|
||||
/// </summary>
|
||||
internal static ushort RegisterCount(ModbusTagDefinition tag) => tag.DataType switch
|
||||
{
|
||||
ModbusDataType.Int16 or ModbusDataType.UInt16 or ModbusDataType.BitInRegister => 1,
|
||||
ModbusDataType.Int32 or ModbusDataType.UInt32 or ModbusDataType.Float32 => 2,
|
||||
ModbusDataType.Int64 or ModbusDataType.UInt64 or ModbusDataType.Float64 => 4,
|
||||
ModbusDataType.String => (ushort)((tag.StringLength + 1) / 2), // 2 chars per register
|
||||
_ => throw new InvalidOperationException($"Non-register data type {tag.DataType}"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Word-swap the input into the big-endian layout the decoders expect. For 2-register
|
||||
/// types this reverses the two words; for 4-register types it reverses the four words
|
||||
/// (PLC stored [hi-mid, low-mid, hi-high, low-high] → memory [hi-high, low-high, hi-mid, low-mid]).
|
||||
/// </summary>
|
||||
private static byte[] NormalizeWordOrder(ReadOnlySpan<byte> data, ModbusByteOrder order)
|
||||
{
|
||||
if (order == ModbusByteOrder.BigEndian) return data.ToArray();
|
||||
var result = new byte[data.Length];
|
||||
for (var word = 0; word < data.Length / 2; word++)
|
||||
{
|
||||
var srcWord = data.Length / 2 - 1 - word;
|
||||
result[word * 2] = data[srcWord * 2];
|
||||
result[word * 2 + 1] = data[srcWord * 2 + 1];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static object DecodeRegister(ReadOnlySpan<byte> data, ModbusTagDefinition tag)
|
||||
{
|
||||
switch (tag.DataType)
|
||||
{
|
||||
case ModbusDataType.Int16: return BinaryPrimitives.ReadInt16BigEndian(data);
|
||||
case ModbusDataType.UInt16: return BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||
case ModbusDataType.BitInRegister:
|
||||
{
|
||||
var raw = BinaryPrimitives.ReadUInt16BigEndian(data);
|
||||
return (raw & (1 << tag.BitIndex)) != 0;
|
||||
}
|
||||
case ModbusDataType.Int32:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
return BinaryPrimitives.ReadInt32BigEndian(b);
|
||||
}
|
||||
case ModbusDataType.UInt32:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
return BinaryPrimitives.ReadUInt32BigEndian(b);
|
||||
}
|
||||
case ModbusDataType.Float32:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
return BinaryPrimitives.ReadSingleBigEndian(b);
|
||||
}
|
||||
case ModbusDataType.Int64:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
return BinaryPrimitives.ReadInt64BigEndian(b);
|
||||
}
|
||||
case ModbusDataType.UInt64:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
return BinaryPrimitives.ReadUInt64BigEndian(b);
|
||||
}
|
||||
case ModbusDataType.Float64:
|
||||
{
|
||||
var b = NormalizeWordOrder(data, tag.ByteOrder);
|
||||
return BinaryPrimitives.ReadDoubleBigEndian(b);
|
||||
}
|
||||
case ModbusDataType.String:
|
||||
{
|
||||
// ASCII, 2 chars per register, packed high byte = first char.
|
||||
// Respect the caller's StringLength (truncate nul-padded regions).
|
||||
var chars = new char[tag.StringLength];
|
||||
for (var i = 0; i < tag.StringLength; i++)
|
||||
{
|
||||
var b = data[i];
|
||||
if (b == 0) { return new string(chars, 0, i); }
|
||||
chars[i] = (char)b;
|
||||
}
|
||||
return new string(chars);
|
||||
}
|
||||
default:
|
||||
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
||||
}
|
||||
}
|
||||
|
||||
internal static byte[] EncodeRegister(object? value, ModbusTagDefinition tag)
|
||||
{
|
||||
switch (tag.DataType)
|
||||
{
|
||||
case ModbusDataType.Int16:
|
||||
{
|
||||
var v = Convert.ToInt16(value);
|
||||
var b = new byte[2]; BinaryPrimitives.WriteInt16BigEndian(b, v); return b;
|
||||
}
|
||||
case ModbusDataType.UInt16:
|
||||
{
|
||||
var v = Convert.ToUInt16(value);
|
||||
var b = new byte[2]; BinaryPrimitives.WriteUInt16BigEndian(b, v); return b;
|
||||
}
|
||||
case ModbusDataType.Int32:
|
||||
{
|
||||
var v = Convert.ToInt32(value);
|
||||
var b = new byte[4]; BinaryPrimitives.WriteInt32BigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.UInt32:
|
||||
{
|
||||
var v = Convert.ToUInt32(value);
|
||||
var b = new byte[4]; BinaryPrimitives.WriteUInt32BigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.Float32:
|
||||
{
|
||||
var v = Convert.ToSingle(value);
|
||||
var b = new byte[4]; BinaryPrimitives.WriteSingleBigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.Int64:
|
||||
{
|
||||
var v = Convert.ToInt64(value);
|
||||
var b = new byte[8]; BinaryPrimitives.WriteInt64BigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.UInt64:
|
||||
{
|
||||
var v = Convert.ToUInt64(value);
|
||||
var b = new byte[8]; BinaryPrimitives.WriteUInt64BigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.Float64:
|
||||
{
|
||||
var v = Convert.ToDouble(value);
|
||||
var b = new byte[8]; BinaryPrimitives.WriteDoubleBigEndian(b, v);
|
||||
return NormalizeWordOrder(b, tag.ByteOrder);
|
||||
}
|
||||
case ModbusDataType.String:
|
||||
{
|
||||
var s = Convert.ToString(value) ?? string.Empty;
|
||||
var regs = (tag.StringLength + 1) / 2;
|
||||
var b = new byte[regs * 2];
|
||||
for (var i = 0; i < tag.StringLength && i < s.Length; i++) b[i] = (byte)s[i];
|
||||
// remaining bytes stay 0 — nul-padded per PLC convention
|
||||
return b;
|
||||
}
|
||||
case ModbusDataType.BitInRegister:
|
||||
throw new InvalidOperationException(
|
||||
"BitInRegister writes require a read-modify-write; not supported in PR 24 (separate follow-up).");
|
||||
default:
|
||||
throw new InvalidOperationException($"Non-register data type {tag.DataType}");
|
||||
}
|
||||
}
|
||||
|
||||
private static DriverDataType MapDataType(ModbusDataType t) => t switch
|
||||
{
|
||||
ModbusDataType.Bool or ModbusDataType.BitInRegister => DriverDataType.Boolean,
|
||||
ModbusDataType.Int16 or ModbusDataType.Int32 => DriverDataType.Int32,
|
||||
ModbusDataType.UInt16 or ModbusDataType.UInt32 => DriverDataType.Int32,
|
||||
ModbusDataType.Int64 or ModbusDataType.UInt64 => DriverDataType.Int32, // widening to Int32 loses precision; PR 25 adds Int64 to DriverDataType
|
||||
ModbusDataType.Float32 => DriverDataType.Float32,
|
||||
ModbusDataType.Float64 => DriverDataType.Float64,
|
||||
ModbusDataType.String => DriverDataType.String,
|
||||
_ => DriverDataType.Int32,
|
||||
};
|
||||
|
||||
private IModbusTransport RequireTransport() =>
|
||||
_transport ?? throw new InvalidOperationException("ModbusDriver not initialized");
|
||||
|
||||
private const uint StatusBadInternalError = 0x80020000u;
|
||||
private const uint StatusBadNodeIdUnknown = 0x80340000u;
|
||||
private const uint StatusBadNotWritable = 0x803B0000u;
|
||||
|
||||
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
|
||||
_transport = null;
|
||||
}
|
||||
}
|
||||
97
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
Normal file
97
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriverOptions.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Modbus TCP driver configuration. Bound from the driver's <c>DriverConfig</c> JSON at
|
||||
/// <c>DriverHost.RegisterAsync</c>. Every register the driver exposes appears in
|
||||
/// <see cref="Tags"/>; names become the OPC UA browse name + full reference.
|
||||
/// </summary>
|
||||
public sealed class ModbusDriverOptions
|
||||
{
|
||||
public string Host { get; init; } = "127.0.0.1";
|
||||
public int Port { get; init; } = 502;
|
||||
public byte UnitId { get; init; } = 1;
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Pre-declared tag map. Modbus has no discovery protocol — the driver returns exactly these.</summary>
|
||||
public IReadOnlyList<ModbusTagDefinition> Tags { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Background connectivity-probe settings. When <see cref="ModbusProbeOptions.Enabled"/>
|
||||
/// is true the driver runs a tick loop that issues a cheap FC03 at register 0 every
|
||||
/// <see cref="ModbusProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
|
||||
/// Running ↔ Stopped transitions. The Admin UI / OPC UA clients see the state through
|
||||
/// <see cref="IHostConnectivityProbe"/>.
|
||||
/// </summary>
|
||||
public ModbusProbeOptions Probe { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class ModbusProbeOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
/// <summary>Register to read for the probe. Zero is usually safe; override for PLCs that lock register 0.</summary>
|
||||
public ushort ProbeAddress { get; init; } = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Modbus-backed OPC UA variable. Address is zero-based (Modbus spec numbering, not
|
||||
/// the documentation's 1-based coil/register conventions). Multi-register types
|
||||
/// (Int32/UInt32/Float32 = 2 regs; Int64/UInt64/Float64 = 4 regs) respect the
|
||||
/// <see cref="ByteOrder"/> field — real-world PLCs disagree on word ordering.
|
||||
/// </summary>
|
||||
/// <param name="Name">
|
||||
/// Tag name, used for both the OPC UA browse name and the driver's full reference. Must be
|
||||
/// unique within the driver.
|
||||
/// </param>
|
||||
/// <param name="Region">Coils / DiscreteInputs / InputRegisters / HoldingRegisters.</param>
|
||||
/// <param name="Address">Zero-based address within the region.</param>
|
||||
/// <param name="DataType">
|
||||
/// Logical data type. See <see cref="ModbusDataType"/> for the register count each encodes.
|
||||
/// </param>
|
||||
/// <param name="Writable">When true and Region supports writes (Coils / HoldingRegisters), IWritable routes writes here.</param>
|
||||
/// <param name="ByteOrder">Word ordering for multi-register types. Ignored for Bool / Int16 / UInt16 / BitInRegister / String.</param>
|
||||
/// <param name="BitIndex">For <c>DataType = BitInRegister</c>: which bit of the holding register (0-15, LSB-first).</param>
|
||||
/// <param name="StringLength">For <c>DataType = String</c>: number of ASCII characters (2 per register, rounded up).</param>
|
||||
public sealed record ModbusTagDefinition(
|
||||
string Name,
|
||||
ModbusRegion Region,
|
||||
ushort Address,
|
||||
ModbusDataType DataType,
|
||||
bool Writable = true,
|
||||
ModbusByteOrder ByteOrder = ModbusByteOrder.BigEndian,
|
||||
byte BitIndex = 0,
|
||||
ushort StringLength = 0);
|
||||
|
||||
public enum ModbusRegion { Coils, DiscreteInputs, InputRegisters, HoldingRegisters }
|
||||
|
||||
public enum ModbusDataType
|
||||
{
|
||||
Bool,
|
||||
Int16,
|
||||
UInt16,
|
||||
Int32,
|
||||
UInt32,
|
||||
Int64,
|
||||
UInt64,
|
||||
Float32,
|
||||
Float64,
|
||||
/// <summary>Single bit within a holding register. <see cref="ModbusTagDefinition.BitIndex"/> selects 0-15 LSB-first.</summary>
|
||||
BitInRegister,
|
||||
/// <summary>ASCII string packed 2 chars per register, <see cref="ModbusTagDefinition.StringLength"/> characters long.</summary>
|
||||
String,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Word ordering for multi-register types. Modbus TCP standard is <see cref="BigEndian"/>
|
||||
/// (ABCD for 32-bit: high word at the lower address). Many PLCs — Siemens S7, several
|
||||
/// Allen-Bradley series, some Modicon families — use <see cref="WordSwap"/> (CDAB), which
|
||||
/// keeps bytes big-endian within each register but reverses the word pair(s).
|
||||
/// </summary>
|
||||
public enum ModbusByteOrder
|
||||
{
|
||||
BigEndian,
|
||||
WordSwap,
|
||||
}
|
||||
113
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
Normal file
113
src/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusTcpTransport.cs
Normal file
@@ -0,0 +1,113 @@
|
||||
using System.Net.Sockets;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
/// <summary>
|
||||
/// Concrete Modbus TCP transport. Wraps a single <see cref="TcpClient"/> and serializes
|
||||
/// requests so at most one transaction is in-flight at a time — Modbus servers typically
|
||||
/// support concurrent transactions, but the single-flight model keeps the wire trace
|
||||
/// easy to diagnose and avoids interleaved-response correlation bugs.
|
||||
/// </summary>
|
||||
public sealed class ModbusTcpTransport : IModbusTransport
|
||||
{
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly TimeSpan _timeout;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private TcpClient? _client;
|
||||
private NetworkStream? _stream;
|
||||
private ushort _nextTx;
|
||||
private bool _disposed;
|
||||
|
||||
public ModbusTcpTransport(string host, int port, TimeSpan timeout)
|
||||
{
|
||||
_host = host;
|
||||
_port = port;
|
||||
_timeout = timeout;
|
||||
}
|
||||
|
||||
public async Task ConnectAsync(CancellationToken ct)
|
||||
{
|
||||
_client = new TcpClient();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _client.ConnectAsync(_host, _port, cts.Token).ConfigureAwait(false);
|
||||
_stream = _client.GetStream();
|
||||
}
|
||||
|
||||
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
|
||||
if (_stream is null) throw new InvalidOperationException("Transport not connected");
|
||||
|
||||
await _gate.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
var txId = ++_nextTx;
|
||||
|
||||
// MBAP: [TxId(2)][Proto=0(2)][Length(2)][UnitId(1)] + PDU
|
||||
var adu = new byte[7 + pdu.Length];
|
||||
adu[0] = (byte)(txId >> 8);
|
||||
adu[1] = (byte)(txId & 0xFF);
|
||||
// protocol id already zero
|
||||
var len = (ushort)(1 + pdu.Length); // unit id + pdu
|
||||
adu[4] = (byte)(len >> 8);
|
||||
adu[5] = (byte)(len & 0xFF);
|
||||
adu[6] = unitId;
|
||||
Buffer.BlockCopy(pdu, 0, adu, 7, pdu.Length);
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_timeout);
|
||||
await _stream.WriteAsync(adu.AsMemory(), cts.Token).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var header = new byte[7];
|
||||
await ReadExactlyAsync(_stream, header, cts.Token).ConfigureAwait(false);
|
||||
var respTxId = (ushort)((header[0] << 8) | header[1]);
|
||||
if (respTxId != txId)
|
||||
throw new InvalidDataException($"Modbus TxId mismatch: expected {txId} got {respTxId}");
|
||||
var respLen = (ushort)((header[4] << 8) | header[5]);
|
||||
if (respLen < 1) throw new InvalidDataException($"Modbus response length too small: {respLen}");
|
||||
var respPdu = new byte[respLen - 1];
|
||||
await ReadExactlyAsync(_stream, respPdu, cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Exception PDU: function code has high bit set.
|
||||
if ((respPdu[0] & 0x80) != 0)
|
||||
{
|
||||
var fc = (byte)(respPdu[0] & 0x7F);
|
||||
var ex = respPdu[1];
|
||||
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
|
||||
}
|
||||
|
||||
return respPdu;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
|
||||
{
|
||||
var read = 0;
|
||||
while (read < buf.Length)
|
||||
{
|
||||
var n = await s.ReadAsync(buf.AsMemory(read), ct).ConfigureAwait(false);
|
||||
if (n == 0) throw new EndOfStreamException("Modbus socket closed mid-response");
|
||||
read += n;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try
|
||||
{
|
||||
if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
_client?.Dispose();
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;
|
||||
@@ -35,6 +36,12 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
private FolderState? _driverRoot;
|
||||
private readonly Dictionary<string, BaseDataVariableState> _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// PR 26: SecurityClassification per variable, populated during Variable() registration.
|
||||
// OnWriteValue looks up the classification here to gate the write by the session's roles.
|
||||
// Drivers never enforce authz themselves — the classification is discovery-time metadata
|
||||
// only (feedback_acl_at_server_layer.md).
|
||||
private readonly Dictionary<string, SecurityClassification> _securityByFullRef = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Active building folder — set per Folder() call so Variable() lands under the right parent.
|
||||
// A stack would support nested folders; we use a single current folder because IAddressSpaceBuilder
|
||||
// returns a child builder per Folder call and the caller threads nesting through those references.
|
||||
@@ -122,6 +129,7 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
_currentFolder.AddChild(v);
|
||||
AddPredefinedNode(SystemContext, v);
|
||||
_variablesByFullRef[attributeInfo.FullName] = v;
|
||||
_securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
|
||||
|
||||
v.OnReadValue = OnReadValue;
|
||||
v.OnWriteValue = OnWriteValue;
|
||||
@@ -337,6 +345,22 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
var fullRef = node.NodeId.Identifier as string;
|
||||
if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;
|
||||
|
||||
// PR 26: server-layer write authorization. Look up the attribute's classification
|
||||
// (populated during Variable() in Discover) and check the session's roles against the
|
||||
// policy table. Drivers don't participate in this decision — IWritable.WriteAsync
|
||||
// never sees a request we'd have refused here.
|
||||
if (_securityByFullRef.TryGetValue(fullRef!, out var classification))
|
||||
{
|
||||
var roles = context.UserIdentity is IRoleBearer rb ? rb.Roles : [];
|
||||
if (!WriteAuthzPolicy.IsAllowed(classification, roles))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Write denied for {FullRef}: classification={Classification} userRoles=[{Roles}]",
|
||||
fullRef, classification, string.Join(",", roles));
|
||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var results = _writable.WriteAsync(
|
||||
|
||||
@@ -97,7 +97,7 @@ public sealed class OtOpcUaServer : StandardServer
|
||||
/// managers can gate writes by role via <c>session.Identity</c>. Anonymous identity still
|
||||
/// uses the stack's default.
|
||||
/// </summary>
|
||||
private sealed class RoleBasedIdentity : UserIdentity
|
||||
private sealed class RoleBasedIdentity : UserIdentity, IRoleBearer
|
||||
{
|
||||
public IReadOnlyList<string> Roles { get; }
|
||||
public string? Display { get; }
|
||||
|
||||
13
src/ZB.MOM.WW.OtOpcUa.Server/Security/IRoleBearer.cs
Normal file
13
src/ZB.MOM.WW.OtOpcUa.Server/Security/IRoleBearer.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal interface a <see cref="Opc.Ua.IUserIdentity"/> implementation can expose so
|
||||
/// <see cref="ZB.MOM.WW.OtOpcUa.Server.OpcUa.DriverNodeManager"/> can read the session's
|
||||
/// resolved roles without a hard dependency on any specific identity subtype. Implemented
|
||||
/// by <c>OtOpcUaServer.RoleBasedIdentity</c>; tests implement it with stub identities to
|
||||
/// drive the authz policy under different role sets.
|
||||
/// </summary>
|
||||
public interface IRoleBearer
|
||||
{
|
||||
IReadOnlyList<string> Roles { get; }
|
||||
}
|
||||
70
src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs
Normal file
70
src/ZB.MOM.WW.OtOpcUa.Server/Security/WriteAuthzPolicy.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Server-layer write-authorization policy. ACL enforcement lives here — drivers report
|
||||
/// <see cref="SecurityClassification"/> as discovery metadata only; the server decides
|
||||
/// whether a given session is allowed to write a given attribute by checking the session's
|
||||
/// roles (resolved at login via <see cref="LdapUserAuthenticator"/>) against the required
|
||||
/// role for the attribute's classification.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Matches the table in <c>docs/Configuration.md</c>:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>FreeAccess</c>: no role required — anonymous sessions can write (matches v1 default).</item>
|
||||
/// <item><c>Operate</c> / <c>SecuredWrite</c>: <c>WriteOperate</c> role required.</item>
|
||||
/// <item><c>Tune</c>: <c>WriteTune</c> role required.</item>
|
||||
/// <item><c>VerifiedWrite</c> / <c>Configure</c>: <c>WriteConfigure</c> role required.</item>
|
||||
/// <item><c>ViewOnly</c>: no role grants write access.</item>
|
||||
/// </list>
|
||||
/// <c>AlarmAck</c> is checked at the alarm-acknowledge path, not here.
|
||||
/// </remarks>
|
||||
public static class WriteAuthzPolicy
|
||||
{
|
||||
public const string RoleWriteOperate = "WriteOperate";
|
||||
public const string RoleWriteTune = "WriteTune";
|
||||
public const string RoleWriteConfigure = "WriteConfigure";
|
||||
|
||||
/// <summary>
|
||||
/// Decide whether a session with <paramref name="userRoles"/> is allowed to write to an
|
||||
/// attribute with the given <paramref name="classification"/>. Returns true for
|
||||
/// <c>FreeAccess</c> regardless of roles (including empty / anonymous sessions) and
|
||||
/// false for <c>ViewOnly</c> regardless of roles. Every other classification requires
|
||||
/// the session to carry the mapped role — case-insensitive match.
|
||||
/// </summary>
|
||||
public static bool IsAllowed(SecurityClassification classification, IReadOnlyCollection<string> userRoles)
|
||||
{
|
||||
if (classification == SecurityClassification.FreeAccess) return true;
|
||||
if (classification == SecurityClassification.ViewOnly) return false;
|
||||
|
||||
var required = RequiredRole(classification);
|
||||
if (required is null) return false;
|
||||
|
||||
foreach (var r in userRoles)
|
||||
{
|
||||
if (string.Equals(r, required, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Required role for a classification, or null when no role grants access
|
||||
/// (<see cref="SecurityClassification.ViewOnly"/>) or no role is needed
|
||||
/// (<see cref="SecurityClassification.FreeAccess"/> — also returns null; callers use
|
||||
/// <see cref="IsAllowed"/> which handles the special-cases rather than branching on
|
||||
/// null themselves).
|
||||
/// </summary>
|
||||
public static string? RequiredRole(SecurityClassification classification) => classification switch
|
||||
{
|
||||
SecurityClassification.FreeAccess => null, // IsAllowed short-circuits
|
||||
SecurityClassification.Operate => RoleWriteOperate,
|
||||
SecurityClassification.SecuredWrite => RoleWriteOperate,
|
||||
SecurityClassification.Tune => RoleWriteTune,
|
||||
SecurityClassification.VerifiedWrite => RoleWriteConfigure,
|
||||
SecurityClassification.Configure => RoleWriteConfigure,
|
||||
SecurityClassification.ViewOnly => null, // IsAllowed short-circuits
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using System.Buffers.Binary;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusDataTypeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Register-count lookup is per-tag now (strings need StringLength; Int64/Float64 need 4).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(ModbusDataType.BitInRegister, 1)]
|
||||
[InlineData(ModbusDataType.Int16, 1)]
|
||||
[InlineData(ModbusDataType.UInt16, 1)]
|
||||
[InlineData(ModbusDataType.Int32, 2)]
|
||||
[InlineData(ModbusDataType.UInt32, 2)]
|
||||
[InlineData(ModbusDataType.Float32, 2)]
|
||||
[InlineData(ModbusDataType.Int64, 4)]
|
||||
[InlineData(ModbusDataType.UInt64, 4)]
|
||||
[InlineData(ModbusDataType.Float64, 4)]
|
||||
public void RegisterCount_returns_correct_register_count_per_type(ModbusDataType t, int expected)
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, t);
|
||||
ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, 1)] // 0 chars → still 1 byte / 1 register (pathological but well-defined: length 0 is 0 bytes)
|
||||
[InlineData(1, 1)]
|
||||
[InlineData(2, 1)]
|
||||
[InlineData(3, 2)]
|
||||
[InlineData(10, 5)]
|
||||
public void RegisterCount_for_String_rounds_up_to_register_pair(ushort chars, int expectedRegs)
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String, StringLength: chars);
|
||||
// 0-char is encoded as 0 regs; the test case expects 1 for lengths 1-2, 2 for 3-4, etc.
|
||||
if (chars == 0) ModbusDriver.RegisterCount(tag).ShouldBe((ushort)0);
|
||||
else ModbusDriver.RegisterCount(tag).ShouldBe((ushort)expectedRegs);
|
||||
}
|
||||
|
||||
// --- Int32 / UInt32 / Float32 with byte-order variants ---
|
||||
|
||||
[Fact]
|
||||
public void Int32_BigEndian_decodes_ABCD_layout()
|
||||
{
|
||||
// Value 0x12345678 → bytes [0x12, 0x34, 0x56, 0x78] as PLC wrote them.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
|
||||
ByteOrder: ModbusByteOrder.BigEndian);
|
||||
var bytes = new byte[] { 0x12, 0x34, 0x56, 0x78 };
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Int32_WordSwap_decodes_CDAB_layout()
|
||||
{
|
||||
// Siemens/AB PLC stored 0x12345678 as register[0] = 0x5678, register[1] = 0x1234.
|
||||
// Wire bytes are [0x56, 0x78, 0x12, 0x34]; with ByteOrder=WordSwap we get 0x12345678 back.
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int32,
|
||||
ByteOrder: ModbusByteOrder.WordSwap);
|
||||
var bytes = new byte[] { 0x56, 0x78, 0x12, 0x34 };
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(0x12345678);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Float32_WordSwap_encode_decode_roundtrips()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float32,
|
||||
ByteOrder: ModbusByteOrder.WordSwap);
|
||||
var wire = ModbusDriver.EncodeRegister(25.5f, tag);
|
||||
wire.Length.ShouldBe(4);
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(25.5f);
|
||||
}
|
||||
|
||||
// --- Int64 / UInt64 / Float64 ---
|
||||
|
||||
[Fact]
|
||||
public void Int64_BigEndian_roundtrips()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int64);
|
||||
var wire = ModbusDriver.EncodeRegister(0x0123456789ABCDEFL, tag);
|
||||
wire.Length.ShouldBe(8);
|
||||
BinaryPrimitives.ReadInt64BigEndian(wire).ShouldBe(0x0123456789ABCDEFL);
|
||||
ModbusDriver.DecodeRegister(wire, tag).ShouldBe(0x0123456789ABCDEFL);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UInt64_WordSwap_reverses_four_words()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.UInt64,
|
||||
ByteOrder: ModbusByteOrder.WordSwap);
|
||||
var value = 0xAABBCCDDEEFF0011UL;
|
||||
|
||||
var wireBE = new byte[8];
|
||||
BinaryPrimitives.WriteUInt64BigEndian(wireBE, value);
|
||||
|
||||
// Word-swap layout: [word3, word2, word1, word0] where each word keeps its bytes big-endian.
|
||||
var wireWS = new byte[] { wireBE[6], wireBE[7], wireBE[4], wireBE[5], wireBE[2], wireBE[3], wireBE[0], wireBE[1] };
|
||||
ModbusDriver.DecodeRegister(wireWS, tag).ShouldBe(value);
|
||||
|
||||
var roundtrip = ModbusDriver.EncodeRegister(value, tag);
|
||||
roundtrip.ShouldBe(wireWS);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Float64_roundtrips_under_word_swap()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Float64,
|
||||
ByteOrder: ModbusByteOrder.WordSwap);
|
||||
var wire = ModbusDriver.EncodeRegister(3.14159265358979d, tag);
|
||||
wire.Length.ShouldBe(8);
|
||||
((double)ModbusDriver.DecodeRegister(wire, tag)!).ShouldBe(3.14159265358979d, tolerance: 1e-12);
|
||||
}
|
||||
|
||||
// --- BitInRegister ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(0b0000_0000_0000_0001, 0, true)]
|
||||
[InlineData(0b0000_0000_0000_0001, 1, false)]
|
||||
[InlineData(0b1000_0000_0000_0000, 15, true)]
|
||||
[InlineData(0b0100_0000_0100_0000, 6, true)]
|
||||
[InlineData(0b0100_0000_0100_0000, 14, true)]
|
||||
[InlineData(0b0100_0000_0100_0000, 7, false)]
|
||||
public void BitInRegister_extracts_bit_at_index(ushort raw, byte bitIndex, bool expected)
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
|
||||
BitIndex: bitIndex);
|
||||
var bytes = new byte[] { (byte)(raw >> 8), (byte)(raw & 0xFF) };
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BitInRegister_write_is_not_supported_in_PR24()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.BitInRegister,
|
||||
BitIndex: 5);
|
||||
Should.Throw<InvalidOperationException>(() => ModbusDriver.EncodeRegister(true, tag))
|
||||
.Message.ShouldContain("read-modify-write");
|
||||
}
|
||||
|
||||
// --- String ---
|
||||
|
||||
[Fact]
|
||||
public void String_decodes_ASCII_packed_two_chars_per_register()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 6);
|
||||
// "HELLO!" = 0x48 0x45 0x4C 0x4C 0x4F 0x21 across 3 registers.
|
||||
var bytes = "HELLO!"u8.ToArray();
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("HELLO!");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_decode_truncates_at_first_nul()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 10);
|
||||
var bytes = new byte[] { 0x48, 0x69, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };
|
||||
ModbusDriver.DecodeRegister(bytes, tag).ShouldBe("Hi");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void String_encode_nul_pads_remaining_bytes()
|
||||
{
|
||||
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
|
||||
StringLength: 8);
|
||||
var wire = ModbusDriver.EncodeRegister("Hi", tag);
|
||||
wire.Length.ShouldBe(8);
|
||||
wire[0].ShouldBe((byte)'H');
|
||||
wire[1].ShouldBe((byte)'i');
|
||||
for (var i = 2; i < 8; i++) wire[i].ShouldBe((byte)0);
|
||||
}
|
||||
}
|
||||
244
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverTests.cs
Normal file
244
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusDriverTests.cs
Normal file
@@ -0,0 +1,244 @@
|
||||
using System.Buffers.Binary;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusDriverTests
|
||||
{
|
||||
/// <summary>
|
||||
/// In-memory Modbus TCP server impl that speaks the function codes the driver uses.
|
||||
/// Maintains a register/coil bank so Read/Write round-trips work.
|
||||
/// </summary>
|
||||
private sealed class FakeTransport : IModbusTransport
|
||||
{
|
||||
public readonly ushort[] HoldingRegisters = new ushort[256];
|
||||
public readonly ushort[] InputRegisters = new ushort[256];
|
||||
public readonly bool[] Coils = new bool[256];
|
||||
public readonly bool[] DiscreteInputs = new bool[256];
|
||||
public bool ForceConnectFail { get; set; }
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct)
|
||||
=> ForceConnectFail ? Task.FromException(new InvalidOperationException("connect refused")) : Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
var fc = pdu[0];
|
||||
return fc switch
|
||||
{
|
||||
0x01 => Task.FromResult(ReadBits(pdu, Coils)),
|
||||
0x02 => Task.FromResult(ReadBits(pdu, DiscreteInputs)),
|
||||
0x03 => Task.FromResult(ReadRegs(pdu, HoldingRegisters)),
|
||||
0x04 => Task.FromResult(ReadRegs(pdu, InputRegisters)),
|
||||
0x05 => Task.FromResult(WriteCoil(pdu)),
|
||||
0x06 => Task.FromResult(WriteSingleReg(pdu)),
|
||||
0x10 => Task.FromResult(WriteMultipleRegs(pdu)),
|
||||
_ => Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} not supported by fake")),
|
||||
};
|
||||
}
|
||||
|
||||
private byte[] ReadBits(byte[] pdu, bool[] bank)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var byteCount = (byte)((qty + 7) / 8);
|
||||
var resp = new byte[2 + byteCount];
|
||||
resp[0] = pdu[0];
|
||||
resp[1] = byteCount;
|
||||
for (var i = 0; i < qty; i++)
|
||||
if (bank[addr + i]) resp[2 + (i / 8)] |= (byte)(1 << (i % 8));
|
||||
return resp;
|
||||
}
|
||||
|
||||
private byte[] ReadRegs(byte[] pdu, ushort[] bank)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var byteCount = (byte)(qty * 2);
|
||||
var resp = new byte[2 + byteCount];
|
||||
resp[0] = pdu[0];
|
||||
resp[1] = byteCount;
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(bank[addr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(bank[addr + i] & 0xFF);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
private byte[] WriteCoil(byte[] pdu)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
Coils[addr] = pdu[3] == 0xFF;
|
||||
return pdu; // Modbus echoes the request on write success
|
||||
}
|
||||
|
||||
private byte[] WriteSingleReg(byte[] pdu)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
HoldingRegisters[addr] = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
return pdu;
|
||||
}
|
||||
|
||||
private byte[] WriteMultipleRegs(byte[] pdu)
|
||||
{
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
for (var i = 0; i < qty; i++)
|
||||
HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
|
||||
return new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] };
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static (ModbusDriver driver, FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags)
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = tags };
|
||||
var drv = new ModbusDriver(opts, "modbus-1", _ => fake);
|
||||
return (drv, fake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initialize_connects_and_populates_tag_map()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Int16_holding_register_returns_BigEndian_value()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 10, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[10] = 12345;
|
||||
|
||||
var r = await drv.ReadAsync(["Level"], CancellationToken.None);
|
||||
r[0].Value.ShouldBe((short)12345);
|
||||
r[0].StatusCode.ShouldBe(0u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Float32_spans_two_registers_BigEndian()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// IEEE 754 single for 25.5f is 0x41CC0000 — [41 CC][00 00] big-endian across two regs.
|
||||
var bytes = new byte[4];
|
||||
BinaryPrimitives.WriteSingleBigEndian(bytes, 25.5f);
|
||||
fake.HoldingRegisters[4] = (ushort)((bytes[0] << 8) | bytes[1]);
|
||||
fake.HoldingRegisters[5] = (ushort)((bytes[2] << 8) | bytes[3]);
|
||||
|
||||
var r = await drv.ReadAsync(["Temp"], CancellationToken.None);
|
||||
r[0].Value.ShouldBe(25.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_Coil_returns_boolean()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Run", ModbusRegion.Coils, 3, ModbusDataType.Bool));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.Coils[3] = true;
|
||||
|
||||
var r = await drv.ReadAsync(["Run"], CancellationToken.None);
|
||||
r[0].Value.ShouldBe(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_tag_returns_BadNodeIdUnknown_not_an_exception()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["DoesNotExist"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(0x80340000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_UInt16_holding_register_roundtrips()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Setpoint", ModbusRegion.HoldingRegisters, 20, ModbusDataType.UInt16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync([new WriteRequest("Setpoint", (ushort)42000)], CancellationToken.None);
|
||||
results[0].StatusCode.ShouldBe(0u);
|
||||
fake.HoldingRegisters[20].ShouldBe((ushort)42000);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_Float32_uses_FC16_WriteMultipleRegisters()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Temp", 25.5f)], CancellationToken.None);
|
||||
|
||||
// Decode back through the fake bank to check the two-register shape.
|
||||
var raw = new byte[4];
|
||||
raw[0] = (byte)(fake.HoldingRegisters[4] >> 8);
|
||||
raw[1] = (byte)(fake.HoldingRegisters[4] & 0xFF);
|
||||
raw[2] = (byte)(fake.HoldingRegisters[5] >> 8);
|
||||
raw[3] = (byte)(fake.HoldingRegisters[5] & 0xFF);
|
||||
BinaryPrimitives.ReadSingleBigEndian(raw).ShouldBe(25.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_to_InputRegister_returns_BadNotWritable()
|
||||
{
|
||||
var (drv, _) = NewDriver(new ModbusTagDefinition("Ro", ModbusRegion.InputRegisters, 0, ModbusDataType.UInt16, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Ro", (ushort)7)], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(0x803B0000u);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Discover_streams_one_folder_per_driver_with_a_variable_per_tag()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 4, ModbusDataType.Float32),
|
||||
new ModbusTagDefinition("Run", ModbusRegion.Coils, 0, ModbusDataType.Bool));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var builder = new RecordingBuilder();
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.Count.ShouldBe(1);
|
||||
builder.Folders[0].BrowseName.ShouldBe("Modbus");
|
||||
builder.Variables.Count.ShouldBe(3);
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "Level" && v.Info.DriverDataType == DriverDataType.Int32);
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "Temp" && v.Info.DriverDataType == DriverDataType.Float32);
|
||||
builder.Variables.ShouldContain(v => v.BrowseName == "Run" && v.Info.DriverDataType == DriverDataType.Boolean);
|
||||
}
|
||||
|
||||
// --- 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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
208
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusProbeTests.cs
Normal file
208
tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusProbeTests.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusProbeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Transport fake the probe tests flip between "responding" and "unreachable" to
|
||||
/// exercise the state machine. Calls to SendAsync with FC=0x03 count as probe traffic
|
||||
/// (the driver's probe loop issues exactly that shape).
|
||||
/// </summary>
|
||||
private sealed class FlappyTransport : IModbusTransport
|
||||
{
|
||||
public volatile bool Reachable = true;
|
||||
public int ProbeCount;
|
||||
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (pdu[0] == 0x03) Interlocked.Increment(ref ProbeCount);
|
||||
if (!Reachable)
|
||||
return Task.FromException<byte[]>(new IOException("transport unreachable"));
|
||||
|
||||
// Happy path — return a valid FC03 response for 1 register at addr.
|
||||
if (pdu[0] == 0x03)
|
||||
{
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var resp = new byte[2 + qty * 2];
|
||||
resp[0] = 0x03;
|
||||
resp[1] = (byte)(qty * 2);
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
return Task.FromException<byte[]>(new NotSupportedException());
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static (ModbusDriver drv, FlappyTransport fake) NewDriver(ModbusProbeOptions probe)
|
||||
{
|
||||
var fake = new FlappyTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Port = 502, Probe = probe };
|
||||
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initial_state_is_Unknown_before_first_probe_tick()
|
||||
{
|
||||
var (drv, _) = NewDriver(new ModbusProbeOptions { Enabled = false });
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var statuses = drv.GetHostStatuses();
|
||||
statuses.Count.ShouldBe(1);
|
||||
statuses[0].State.ShouldBe(HostState.Unknown);
|
||||
statuses[0].HostName.ShouldBe("fake:502");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task First_successful_probe_transitions_to_Running()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(150),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
});
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for the first probe to complete.
|
||||
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||
while (fake.ProbeCount == 0 && DateTime.UtcNow < deadline) await Task.Delay(25);
|
||||
|
||||
// Then wait for the event to actually arrive.
|
||||
deadline = DateTime.UtcNow + TimeSpan.FromSeconds(1);
|
||||
while (transitions.Count == 0 && DateTime.UtcNow < deadline) await Task.Delay(25);
|
||||
|
||||
transitions.Count.ShouldBeGreaterThanOrEqualTo(1);
|
||||
transitions.TryDequeue(out var t).ShouldBeTrue();
|
||||
t!.OldState.ShouldBe(HostState.Unknown);
|
||||
t.NewState.ShouldBe(HostState.Running);
|
||||
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Running);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Transport_failure_transitions_to_Stopped()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(150),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
});
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
||||
|
||||
fake.Reachable = false;
|
||||
await WaitForStateAsync(drv, HostState.Stopped, TimeSpan.FromSeconds(2));
|
||||
|
||||
transitions.Select(t => t.NewState).ShouldContain(HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recovery_transitions_Stopped_back_to_Running()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(150),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
});
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
||||
|
||||
fake.Reachable = false;
|
||||
await WaitForStateAsync(drv, HostState.Stopped, TimeSpan.FromSeconds(2));
|
||||
|
||||
fake.Reachable = true;
|
||||
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
||||
|
||||
// We expect at minimum: Unknown→Running, Running→Stopped, Stopped→Running.
|
||||
transitions.Count.ShouldBeGreaterThanOrEqualTo(3);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeated_successful_probes_do_not_generate_duplicate_Running_events()
|
||||
{
|
||||
var (drv, _) = NewDriver(new ModbusProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
});
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
||||
await Task.Delay(500); // several more probe ticks, all successful — state shouldn't thrash
|
||||
|
||||
transitions.Count.ShouldBe(1); // only the initial Unknown→Running
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Disabled_probe_stays_Unknown_and_fires_no_events()
|
||||
{
|
||||
var (drv, _) = NewDriver(new ModbusProbeOptions { Enabled = false });
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
|
||||
transitions.Count.ShouldBe(0);
|
||||
drv.GetHostStatuses()[0].State.ShouldBe(HostState.Unknown);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Shutdown_stops_the_probe_loop()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusProbeOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
});
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForStateAsync(drv, HostState.Running, TimeSpan.FromSeconds(2));
|
||||
|
||||
var before = fake.ProbeCount;
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
await Task.Delay(400);
|
||||
|
||||
// A handful of in-flight ticks may complete after shutdown in a narrow race; the
|
||||
// contract is that the loop stops scheduling new ones. Tolerate ≤1 extra.
|
||||
(fake.ProbeCount - before).ShouldBeLessThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
private static async Task WaitForStateAsync(ModbusDriver drv, HostState expected, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (drv.GetHostStatuses()[0].State == expected) return;
|
||||
await Task.Delay(25);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class ModbusSubscriptionTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Lightweight fake transport the subscription tests drive through — only the FC03
|
||||
/// (Read Holding Registers) path is used. Mutating <see cref="HoldingRegisters"/>
|
||||
/// between polls is how each test simulates a PLC value change.
|
||||
/// </summary>
|
||||
private sealed class FakeTransport : IModbusTransport
|
||||
{
|
||||
public readonly ushort[] HoldingRegisters = new ushort[256];
|
||||
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
|
||||
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
|
||||
{
|
||||
if (pdu[0] != 0x03) return Task.FromException<byte[]>(new NotSupportedException("FC not supported"));
|
||||
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
|
||||
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
|
||||
var resp = new byte[2 + qty * 2];
|
||||
resp[0] = 0x03;
|
||||
resp[1] = (byte)(qty * 2);
|
||||
for (var i = 0; i < qty; i++)
|
||||
{
|
||||
resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8);
|
||||
resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF);
|
||||
}
|
||||
return Task.FromResult(resp);
|
||||
}
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static (ModbusDriver drv, FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags)
|
||||
{
|
||||
var fake = new FakeTransport();
|
||||
var opts = new ModbusDriverOptions { Host = "fake", Tags = tags };
|
||||
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Initial_poll_raises_OnDataChange_for_every_subscribed_tag()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[0] = 100;
|
||||
fake.HoldingRegisters[1] = 200;
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Level", "Temp"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||
await WaitForCountAsync(events, 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.Select(e => e.FullReference).ShouldContain("Level");
|
||||
events.Select(e => e.FullReference).ShouldContain("Temp");
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unchanged_values_do_not_raise_after_initial_poll()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[0] = 100;
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await Task.Delay(500); // ~5 poll cycles at 100ms, value stable the whole time
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
events.Count.ShouldBe(1); // only the initial-data push, no change events after
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Value_change_between_polls_raises_OnDataChange()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.HoldingRegisters[0] = 100;
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForCountAsync(events, 1, TimeSpan.FromSeconds(1));
|
||||
fake.HoldingRegisters[0] = 200; // simulate PLC update
|
||||
await WaitForCountAsync(events, 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
events.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
events.Last().Snapshot.Value.ShouldBe((short)200);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unsubscribe_stops_the_polling_loop()
|
||||
{
|
||||
var (drv, fake) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForCountAsync(events, 1, TimeSpan.FromSeconds(1));
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
var countAfterUnsub = events.Count;
|
||||
fake.HoldingRegisters[0] = 999; // would trigger a change if still polling
|
||||
await Task.Delay(400);
|
||||
events.Count.ShouldBe(countAfterUnsub);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SubscribeAsync_floors_intervals_below_100ms()
|
||||
{
|
||||
var (drv, _) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// 10ms requested — implementation floors to 100ms. We verify indirectly: over 300ms, a
|
||||
// 10ms interval would produce many more events than a 100ms interval would on a stable
|
||||
// value. Since the value is unchanged, we only expect the initial-data push (1 event).
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["Level"], TimeSpan.FromMilliseconds(10), CancellationToken.None);
|
||||
await Task.Delay(300);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Multiple_subscriptions_fire_independently()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new ModbusTagDefinition("A", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
|
||||
new ModbusTagDefinition("B", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var eventsA = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
var eventsB = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) =>
|
||||
{
|
||||
if (e.FullReference == "A") eventsA.Enqueue(e);
|
||||
else if (e.FullReference == "B") eventsB.Enqueue(e);
|
||||
};
|
||||
|
||||
var ha = await drv.SubscribeAsync(["A"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
var hb = await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForCountAsync(eventsA, 1, TimeSpan.FromSeconds(1));
|
||||
await WaitForCountAsync(eventsB, 1, TimeSpan.FromSeconds(1));
|
||||
|
||||
await drv.UnsubscribeAsync(ha, CancellationToken.None);
|
||||
var aCount = eventsA.Count;
|
||||
fake.HoldingRegisters[1] = 77; // only B should pick this up
|
||||
await WaitForCountAsync(eventsB, 2, TimeSpan.FromSeconds(2));
|
||||
|
||||
eventsA.Count.ShouldBe(aCount); // unchanged since unsubscribe
|
||||
eventsB.Count.ShouldBeGreaterThanOrEqualTo(2);
|
||||
await drv.UnsubscribeAsync(hb, CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitForCountAsync<T>(ConcurrentQueue<T> q, int target, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (q.Count < target && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(25);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Modbus\ZB.MOM.WW.OtOpcUa.Driver.Modbus.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
134
tests/ZB.MOM.WW.OtOpcUa.Server.Tests/WriteAuthzPolicyTests.cs
Normal file
134
tests/ZB.MOM.WW.OtOpcUa.Server.Tests/WriteAuthzPolicyTests.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class WriteAuthzPolicyTests
|
||||
{
|
||||
// --- FreeAccess and ViewOnly special-cases ---
|
||||
|
||||
[Fact]
|
||||
public void FreeAccess_allows_write_even_for_empty_role_set()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, []).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FreeAccess_allows_write_for_arbitrary_roles()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.FreeAccess, ["SomeOtherRole"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ViewOnly_denies_write_even_with_every_role()
|
||||
{
|
||||
var allRoles = new[] { "WriteOperate", "WriteTune", "WriteConfigure", "AlarmAck" };
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.ViewOnly, allRoles).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Operate tier ---
|
||||
|
||||
[Fact]
|
||||
public void Operate_requires_WriteOperate_role()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WriteOperate"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Operate_role_match_is_case_insensitive()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["writeoperate"]).ShouldBeTrue();
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["WRITEOPERATE"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Operate_denies_empty_role_set()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, []).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Operate_denies_wrong_role()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Operate, ["ReadOnly"]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SecuredWrite_maps_to_same_WriteOperate_requirement_as_Operate()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteOperate"]).ShouldBeTrue();
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.SecuredWrite, ["WriteTune"]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Tune tier ---
|
||||
|
||||
[Fact]
|
||||
public void Tune_requires_WriteTune_role()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteTune"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tune_denies_WriteOperate_only_session()
|
||||
{
|
||||
// Important: role roles do NOT cascade — a session with WriteOperate can't write a Tune
|
||||
// attribute. Operators escalate by adding WriteTune to the session's roles, not by a
|
||||
// hierarchy the policy infers on its own.
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, ["WriteOperate"]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Configure tier ---
|
||||
|
||||
[Fact]
|
||||
public void Configure_requires_WriteConfigure_role()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, ["WriteConfigure"]).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VerifiedWrite_maps_to_same_WriteConfigure_requirement_as_Configure()
|
||||
{
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteConfigure"]).ShouldBeTrue();
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.VerifiedWrite, ["WriteOperate"]).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Multi-role sessions ---
|
||||
|
||||
[Fact]
|
||||
public void Session_with_multiple_roles_is_allowed_when_any_matches()
|
||||
{
|
||||
var roles = new[] { "ReadOnly", "WriteTune", "AlarmAck" };
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Tune, roles).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_with_only_unrelated_roles_is_denied()
|
||||
{
|
||||
var roles = new[] { "ReadOnly", "AlarmAck", "SomeCustomRole" };
|
||||
WriteAuthzPolicy.IsAllowed(SecurityClassification.Configure, roles).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// --- Mapping table ---
|
||||
|
||||
[Theory]
|
||||
[InlineData(SecurityClassification.Operate, WriteAuthzPolicy.RoleWriteOperate)]
|
||||
[InlineData(SecurityClassification.SecuredWrite, WriteAuthzPolicy.RoleWriteOperate)]
|
||||
[InlineData(SecurityClassification.Tune, WriteAuthzPolicy.RoleWriteTune)]
|
||||
[InlineData(SecurityClassification.VerifiedWrite, WriteAuthzPolicy.RoleWriteConfigure)]
|
||||
[InlineData(SecurityClassification.Configure, WriteAuthzPolicy.RoleWriteConfigure)]
|
||||
public void RequiredRole_returns_expected_role_for_classification(SecurityClassification c, string expected)
|
||||
{
|
||||
WriteAuthzPolicy.RequiredRole(c).ShouldBe(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(SecurityClassification.FreeAccess)]
|
||||
[InlineData(SecurityClassification.ViewOnly)]
|
||||
public void RequiredRole_returns_null_for_special_classifications(SecurityClassification c)
|
||||
{
|
||||
WriteAuthzPolicy.RequiredRole(c).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user