Compare commits

...

47 Commits

Author SHA1 Message Date
Joseph Doherty
a65215684c Phase 3 PR 70 -- Apply SecurityPolicy explicitly + expand to standard OPC UA policy list. Before this PR SecurityPolicy was a string field that got ignored -- the driver only passed useSecurity=SecurityMode!=None to SelectEndpointAsync, so an operator asking for Basic256Sha256 on a server that also advertised Basic128Rsa15 could silently end up on the weaker cipher (the SDK's SelectEndpoint returns whichever matching endpoint the server listed first). PR 70 makes policy matching explicit. SecurityPolicy is now an OpcUaSecurityPolicy enum covering the six standard policies documented in OPC UA 1.04: None, Basic128Rsa15 (deprecated, brownfield interop only), Basic256 (deprecated), Basic256Sha256 (recommended baseline), Aes128_Sha256_RsaOaep, Aes256_Sha256_RsaPss. Each maps through MapSecurityPolicy to the SecurityPolicies URI constant the SDK uses for endpoint matching. New SelectMatchingEndpointAsync replaces CoreClientUtils.SelectEndpointAsync. Flow: opens a DiscoveryClient via the non-obsolete DiscoveryClient.CreateAsync(ApplicationConfiguration, Uri, DiagnosticsMasks, ct) path, calls GetEndpointsAsync to enumerate every endpoint the server advertises, filters client-side by policy URI AND mode. When no endpoint matches, throws InvalidOperationException with the full list of what the server DID advertise formatted as 'Policy/Mode' pairs so the operator sees exactly what to fix in their config without a Wireshark trace. Fail-loud behaviour intentional -- a silent fall-through to weaker crypto is worse than a clear config error. MapSecurityPolicy is internal-visible to tests via InternalsVisibleTo from PR 66. Unit tests (OpcUaClientSecurityPolicyTests, 5 facts): MapSecurityPolicy_returns_known_non_empty_uri_for_every_enum_value theory covers all 6 policies; URI contains the enum name for non-None so operators can grep logs back to the config value; MapSecurityPolicy_None_matches_SDK_None_URI, MapSecurityPolicy_Basic256Sha256_matches_SDK_URI, MapSecurityPolicy_Aes256_Sha256_RsaPss_matches_SDK_URI all cross-check against the SDK's SecurityPolicies.* constants to catch a future enum-vs-URI drift; Every_enum_value_has_a_mapping walks Enum.GetValues to ensure adding a new case doesn't silently fall through the switch. Scaffold test updated to assert SecurityPolicy default = None (was previously unchecked). 23/23 OpcUaClient.Tests pass (13 prior + 5 scaffold + 5 new policy). dotnet build clean. Note on DiscoveryClient: the synchronous DiscoveryClient.Create(...) overloads are all [Obsolete] in SDK 1.5.378; must use DiscoveryClient.CreateAsync. GetEndpointsAsync(null, ct) returns EndpointDescriptionCollection directly (not a wrapper). 2026-04-19 01:44:07 -04:00
82f2dfcfa3 Merge pull request 'Phase 3 PR 69 -- OPC UA Client ISubscribable + IHostConnectivityProbe' (#68) from phase-3-pr69-opcua-client-subscribe-probe into v2 2026-04-19 01:24:21 -04:00
Joseph Doherty
0433d3a35e Phase 3 PR 69 -- OPC UA Client ISubscribable + IHostConnectivityProbe. Completes the OpcUaClientDriver capability surface — now matches the Galaxy + Modbus + S7 driver coverage. ISubscribable: SubscribeAsync creates a new upstream Subscription via the non-obsolete Subscription(ITelemetryContext, SubscriptionOptions) ctor + AddItem/CreateItemsAsync flow, wires each MonitoredItem's Notification event into OnDataChange. Tag strings round-trip through MonitoredItem.Handle so the notification handler can identify which tag changed without a second lookup. Publishing interval floored at 50ms (servers negotiate up anyway; sub-50ms wastes round-trip). SubscriptionOptions uses KeepAliveCount=10, LifetimeCount=1000, TimestampsToReturn=Both so SourceTimestamp passthrough for the cascading-quality rule works through subscription paths too. UnsubscribeAsync calls Subscription.DeleteAsync(silent:true) and tolerates unknown handles (returns cleanly) because the caller's race with server-side cleanup after a session drop shouldn't crash either side. Session shutdown explicitly deletes every remote subscription before closing — avoids BadSubscriptionIdInvalid noise in the upstream server's log on Close. IHostConnectivityProbe: HostName surfaced as the EndpointUrl (not host:port like the Modbus/S7 drivers) so the Admin /hosts dashboard can render the full opc.tcp:// URL as a clickable target back at the remote server. HostState tracked via session.KeepAlive event — OPC UA's built-in keep-alive is authoritative for session liveness (the SDK pings on KeepAliveInterval, sets KeepAliveStopped after N missed pings), strictly better than a driver-side polling probe: no extra wire round-trip, no duplicate semantic with the native protocol. Handler transitions Running on healthy keep-alives and Stopped on any Bad service-result. Initial Running raised at end of InitializeAsync once the session is up; Shutdown transitions back to Unknown + unwires the handler. Unit tests (OpcUaClientSubscribeAndProbeTests, 3 facts): SubscribeAsync_without_initialize_throws_InvalidOperationException, UnsubscribeAsync_with_unknown_handle_is_noop (session-drop-race safety), GetHostStatuses_returns_endpoint_url_row_pre_init (asserts EndpointUrl as the host identity -- the full opc.tcp://plc.example:4840 URL). Live-session subscribe/unsubscribe round-trip + keep-alive state transition coverage lands in a follow-up PR once we scaffold the in-process OPC UA server fixture. 13/13 OpcUaClient.Tests pass. dotnet build clean. All six capability interfaces (IDriver / ITagDiscovery / IReadable / IWritable / ISubscribable / IHostConnectivityProbe) implemented — OPC UA Client driver surface complete. 2026-04-19 01:22:14 -04:00
141673fc80 Merge pull request 'Phase 3 PR 68 -- OPC UA Client ITagDiscovery (Full browse)' (#67) from phase-3-pr68-opcua-client-discovery into v2 2026-04-19 01:19:27 -04:00
Joseph Doherty
db56a95819 Phase 3 PR 68 -- OPC UA Client ITagDiscovery via recursive browse (Full strategy). Adds ITagDiscovery to OpcUaClientDriver. DiscoverAsync opens a single Remote folder on the IAddressSpaceBuilder and recursively browses from the configured root (default: ObjectsFolder i=85; override via OpcUaClientDriverOptions.BrowseRoot for scoped discovery). Browse uses non-obsolete Session.BrowseAsync(RequestHeader, ViewDescription, uint maxReferences, BrowseDescriptionCollection, ct) with HierarchicalReferences forward, subtypes included, NodeClassMask Object+Variable, ResultMask pulling BrowseName + DisplayName + NodeClass + TypeDefinition. Objects become sub-folders via builder.Folder; Variables become builder.Variable entries with FullName set to the NodeId.ToString() serialization so IReadable/IWritable can round-trip without re-resolving. Three safety caps added to OpcUaClientDriverOptions to bound runaway discovery: (1) MaxBrowseDepth default 10 -- deep enough for realistic OPC UA information models, shallow enough that cyclic graphs can't spin the browse forever. (2) MaxDiscoveredNodes default 10_000 -- caps memory on pathological remote servers. Once the cap is hit, recursion short-circuits and the partially-discovered tree is still projected into the local address space (graceful degradation rather than all-or-nothing). (3) BrowseRoot as an opt-in scope restriction string per driver-specs.md \u00A78 -- defaults to ObjectsFolder but operators with 100k-node servers can point it at a single subtree. Visited-set tracks NodeIds already visited to prevent infinite cycles on graphs with non-strict hierarchy (OPC UA models can have back-references). Transient browse failures on a subtree are swallowed -- the sub-branch stops but the rest of discovery continues, matching the Modbus driver's 'transient poll errors don't kill the loop' pattern. The driver's health surface reflects the network-level cascade via the probe loop (PR 69). Deferred to a follow-up PR: DataType resolution via a batch Session.ReadAsync(Attributes.DataType) after the browse so DriverAttributeInfo.DriverDataType is accurate instead of the current conservative DriverDataType.Int32 default; AccessLevel-derived SecurityClass instead of the current ViewOnly default; array-type detection via Attributes.ValueRank + ArrayDimensions. These need an extra wire round-trip per batch of variables + a NodeId -> DriverDataType mapping table; out of scope for PR 68 to keep browse path landable. Unit tests (OpcUaClientDiscoveryTests, 3 facts): DiscoverAsync_without_initialize_throws_InvalidOperationException (pre-init hits RequireSession); DiscoverAsync_rejects_null_builder (ArgumentNullException); Discovery_caps_are_sensible_defaults (asserts 10000 / 10 / null defaults documented above). NullAddressSpaceBuilder stub implements the full IAddressSpaceBuilder shape including IVariableHandle.MarkAsAlarmCondition (throws NotSupportedException since this PR doesn't wire alarms). Live-browse coverage against a real remote server is deferred to the in-process-server-fixture PR. 10/10 OpcUaClient.Tests pass. dotnet build clean. 2026-04-19 01:17:21 -04:00
89bd726fa8 Merge pull request 'Phase 3 PR 67 -- OPC UA Client IReadable + IWritable' (#66) from phase-3-pr67-opcua-client-read-write into v2 2026-04-19 01:15:42 -04:00
Joseph Doherty
238748bc98 Phase 3 PR 67 -- OPC UA Client IReadable + IWritable via Session.ReadAsync/WriteAsync. Adds IReadable + IWritable capabilities to OpcUaClientDriver, routing reads/writes through the session's non-obsolete ReadAsync(RequestHeader, maxAge, TimestampsToReturn, ReadValueIdCollection, ct) and WriteAsync(RequestHeader, WriteValueCollection, ct) overloads (the sync and BeginXxx/EndXxx patterns are all [Obsolete] in SDK 1.5.378). Serializes on the shared Gate from PR 66 so reads + writes + future subscribe + probe don't race on the single session. NodeId parsing: fullReferences use OPC UA's standard serialized NodeId form -- ns=2;s=Demo.Counter, i=2253, ns=4;g=... for GUID, ns=3;b=... for opaque. TryParseNodeId calls NodeId.Parse with the session's MessageContext which honours the server-negotiated namespace URI table. Malformed input surfaces as BadNodeIdInvalid (0x80330000) WITHOUT a wire round-trip -- saves a request for a fault the driver can detect locally. Cascading-quality implementation per driver-specs.md \u00A78: upstream StatusCode, SourceTimestamp, and ServerTimestamp pass through VERBATIM. Bad codes from the remote server stay as the same Bad code (not translated to generic BadInternalError) so downstream clients can distinguish 'upstream value unavailable' from 'local driver bug'. SourceTimestamp is preserved verbatim (null on MinValue guard) so staleness is visible; ServerTimestamp falls back to DateTime.UtcNow if the upstream omitted it, never overwriting a non-zero value. Wire-level exceptions in the Read batch -- transport / timeout / session-dropped -- fan out BadCommunicationError (0x80050000) across every tag in the batch, not BadInternalError, so operators distinguish network reachability from driver faults. Write-side same pattern: successful WriteAsync maps each upstream StatusCode.Code verbatim into the local WriteResult.StatusCode; transport-layer failure fans out BadCommunicationError across the whole batch. WriteValue carries AttributeId=Value + DataValue wrapping Variant(writeValue) -- the SDK handles the type-to-Variant mapping for common CLR types (bool, int, float, string, etc.) so the driver doesn't need a per-type switch. Name disambiguation: the SDK has its own Opc.Ua.WriteRequest type which collides with ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest; method signature uses the fully-qualified Core.Abstractions.WriteRequest. Unit tests (OpcUaClientReadWriteTests, 2 facts): ReadAsync_without_initialize_throws_InvalidOperationException + WriteAsync_without_initialize_throws_InvalidOperationException -- pre-init calls hit RequireSession and fail uniformly. Wire-level round-trip coverage against a live remote server lands in a follow-up PR once we scaffold an in-process OPC UA server fixture (the existing Server project in the solution is a candidate host). 7/7 OpcUaClient.Tests pass (5 scaffold + 2 read/write). dotnet build clean. Scope: ITagDiscovery (browse) + ISubscribable + IHostConnectivityProbe remain deferred to PRs 68-69 which also need namespace-index remapping and reference-counted MonitoredItem forwarding per driver-specs.md \u00A78. 2026-04-19 01:13:34 -04:00
b21d550836 Merge pull request 'Phase 3 PR 66 -- OPC UA Client (gateway) driver scaffold' (#65) from phase-3-pr66-opcua-client-scaffold into v2 2026-04-19 01:10:07 -04:00
Joseph Doherty
91eaf534c8 Phase 3 PR 66 -- OPC UA Client (gateway) driver project scaffold + IDriver session lifecycle. First driver that CONSUMES OPC UA rather than PUBLISHES it -- connects to a remote server and re-exposes its address space through the local OtOpcUa server per driver-specs.md \u00A78. Uses the same OPCFoundation.NetStandard.Opc.Ua.Client package the existing Client.Shared ships (bumped to 1.5.378.106 to match). Builds its own ApplicationConfiguration (cert stores under %LocalAppData%/OtOpcUa/pki so multiple driver instances in one OtOpcUa server process share a trust anchor) rather than reusing Client.Shared -- Client.Shared is oriented at the interactive CLI with different session-lifetime needs (this driver is always-on, needs keep-alive + session transfer on reconnect + multi-year uptime). Navigated the post-refactor 1.5.378 SDK surface: every Session.Create* static is now [Obsolete] in favour of DefaultSessionFactory; CoreClientUtils.SelectEndpoint got the sync overloads deprecated in favour of SelectEndpointAsync with a required ITelemetryContext parameter. Driver passes telemetry: null! to both SelectEndpointAsync + new DefaultSessionFactory(telemetry: null!) -- the SDK's internal default sink handles null gracefully and plumbing a telemetry context through the driver options surface is out of scope (the driver emits its own logs via the DriverHealth surface anyway). ApplicationInstance default ctor is also obsolete; wrapped in #pragma warning disable CS0618 rather than migrate to the ITelemetryContext overload for the same reason. OpcUaClientDriverOptions models driver-specs.md \u00A78 settings: EndpointUrl (default opc.tcp://localhost:4840 IANA-assigned port), SecurityPolicy/SecurityMode/AuthType enums, Username/Password, SessionTimeout=120s + KeepAliveInterval=5s + ReconnectPeriod=5s (defaults from spec), AutoAcceptCertificates=false (production default; dev turns on for self-signed servers), ApplicationUri + SessionName knobs for certificate SAN matching and remote-server session-list identification. OpcUaClientDriver : IDriver: InitializeAsync builds the ApplicationConfiguration, resolves + creates cert if missing via app.CheckApplicationInstanceCertificatesAsync, selects endpoint via CoreClientUtils.SelectEndpointAsync, builds UserIdentity (Anonymous or Username with UTF-8-encoded password bytes -- the legacy string-password ctor went away; Certificate auth deferred), creates session via DefaultSessionFactory.CreateAsync. Health transitions Unknown -> Initializing -> Healthy on success or -> Faulted on failure with best-effort Session.CloseAsync cleanup. ShutdownAsync (async now, not Task.CompletedTask) closes the session + disposes. Internal Session + Gate expose to the test project via InternalsVisibleTo so PRs 67-69 can stack read/write/discovery/subscribe on the same serialization. Scaffold tests (OpcUaClientDriverScaffoldTests, 5 facts): Default_options_target_standard_opcua_port_and_anonymous_auth (4840 + None mode + Anonymous + AutoAccept=false production default), Default_timeouts_match_driver_specs_section_8 (120s/5s/5s), Driver_reports_type_and_id_before_connect (DriverType=OpcUaClient, DriverInstanceId round-trip, pre-init Unknown health), Initialize_against_unreachable_endpoint_transitions_to_Faulted_and_throws, Reinitialize_against_unreachable_endpoint_re_throws. Uses opc.tcp://127.0.0.1:1 as the 'guaranteed-unreachable' target -- RFC 5737 reserved IPs get black-holed and time out only after the SDK's internal retry/backoff fully elapses (~60s), while port 1 on loopback refuses immediately with TCP RST which keeps the test suite snappy (5 tests / 8s). 5/5 pass. dotnet build clean. Scope boundary: ITagDiscovery / IReadable / IWritable / ISubscribable / IHostConnectivityProbe deliberately NOT in this PR -- they need browse + namespace remapping + reference-counted MonitoredItem forwarding + keep-alive probing and land in PRs 67-69. 2026-04-19 01:07:57 -04:00
d33e38e059 Merge pull request 'Phase 3 PR 65 -- S7 ITagDiscovery + ISubscribable + IHostConnectivityProbe' (#64) from phase-3-pr65-s7-discovery-subscribe-probe into v2 2026-04-19 00:18:17 -04:00
Joseph Doherty
d8ef35d5bd Phase 3 PR 65 -- S7 ITagDiscovery + ISubscribable polling overlay + IHostConnectivityProbe. Three more capability interfaces on S7Driver, matching the Modbus driver's capability coverage. ITagDiscovery: DiscoverAsync streams every configured tag into IAddressSpaceBuilder under a single 'S7' folder; builder.Variable gets a DriverAttributeInfo carrying DriverDataType (MapDataType: Bool->Boolean, Byte/Int/UInt sizes->Int32 (until Core.Abstractions adds widths), Float32/Float64 direct, String + DateTime direct), SecurityClass (Operate if tag.Writable else ViewOnly -- matches the Modbus pattern so DriverNodeManager's ACL layer can gate writes per role without S7-specific logic), IsHistorized=false (S7 has no native historian surface), IsAlarm=false (S7 alarms land through TIA Portal's alarm-in-DB pattern which is per-site and out of scope for PR 65). ISubscribable polling overlay: same pattern Modbus established in PR 22. SubscribeAsync spawns a Task.Run loop that polls every tag, diffs against LastValues, raises OnDataChange on changes plus a force-raise on initial-data push per OPC UA Part 4 convention. Interval floored at 100ms -- S7 CPUs scan 2-10ms but process the comms mailbox at most once per scan, so sub-scan polling just queues wire-side with worse latency per S7netplus documented pattern. Poll errors tolerated: first-read fault doesn't kill the loop (caller can't receive initial values but subsequent polls try again); transient poll errors also swallowed so the loop survives a power-cycle + reconnect through the health surface. UnsubscribeAsync cancels the CTS + removes the subscription -- unknown handle is a no-op, not a throw, because the caller's race with server-side cleanup shouldn't crash either side. Shutdown tears down every subscription before disposing the Plc. IHostConnectivityProbe: HostName surfaced as host:port to match Modbus driver convention (Admin /hosts dashboard renders both families uniformly). GetHostStatuses returns one row (single-endpoint driver). ProbeLoopAsync serializes on the shared Gate + calls Plc.ReadStatusAsync (cheap Get-CPU-Status PDU that doubles as an 'is PLC up' check) every Probe.Interval with a Probe.Timeout cap, transitions HostState Unknown/Stopped -> Running on success and -> Stopped on any failure, raises OnHostStatusChanged only on actual transitions (no noise for steady-state probes). Probe loop starts at end of InitializeAsync when Probe.Enabled=true (default); Shutdown cancels the probe CTS. Initial state stays Unknown until first successful probe -- avoids broadcasting a premature Running before any PDU round-trip has happened. Unit tests (S7DiscoveryAndSubscribeTests, 4 facts): DiscoverAsync_projects_every_tag_into_the_address_space (3 tags + mixed writable/read-only -> Operate vs ViewOnly asserted), GetHostStatuses_returns_one_row_with_host_port_identity_pre_init, SubscribeAsync_returns_unique_handles_and_UnsubscribeAsync_accepts_them (diagnosticId uniqueness + idempotent double-unsubscribe), Subscribe_publishing_interval_is_floored_at_100ms (accepts 50ms request without throwing -- floor is applied internally). Uses a RecordingAddressSpaceBuilder stub that implements IVariableHandle.FullReference + MarkAsAlarmCondition (throws NotImplementedException since the S7 driver never calls it -- alarms out of scope). 57/57 S7 unit tests pass. dotnet build clean. All 5 capability interfaces (IDriver/ITagDiscovery/IReadable/IWritable/ISubscribable/IHostConnectivityProbe) now implemented -- the S7 driver surface is on par with the Modbus driver, minus the extended data types (Int64/UInt64/Float64/String/DateTime deferred per PR 64). 2026-04-19 00:16:10 -04:00
5e318a1ab6 Merge pull request 'Phase 3 PR 64 -- S7 IReadable + IWritable via S7.Net' (#63) from phase-3-pr64-s7-read-write into v2 2026-04-19 00:12:59 -04:00
Joseph Doherty
394d126b2e Phase 3 PR 64 -- S7 IReadable + IWritable via S7.Net string-based Plc.ReadAsync/WriteAsync. Adds IReadable + IWritable capability interfaces to S7Driver, routing reads/writes through S7netplus's string-address API (Plc.ReadAsync(string, ct) / Plc.WriteAsync(string, object, ct)). All operations serialize on the class's SemaphoreSlim Gate because S7netplus mandates one Plc connection per PLC with client-side serialization -- parallel reads against a single S7 CPU queue wire-side anyway and just eat connection-resource budget. Supported data types in this PR: Bool, Byte, Int16, UInt16, Int32, UInt32, Float32. S7.Net's string-based read returns UNSIGNED boxed values (DBX=bool, DBB=byte, DBW=ushort, DBD=uint); the driver reinterprets them into the requested S7DataType via the (DataType, Size, raw) switch: unchecked short-cast for Int16, unchecked int-cast for Int32, BitConverter.UInt32BitsToSingle for Float32. Writes inverse the conversion -- Int16 -> unchecked ushort cast, Int32 -> unchecked uint cast, Float32 -> BitConverter.SingleToUInt32Bits -- before handing to S7.Net's WriteAsync. This avoids a second PLC round-trip that a typed ReadAsync(DataType, db, offset, VarType, ...) overload would need. Int64, UInt64, Float64, String, DateTime throw NotSupportedException (-> BadNotSupported StatusCode); S7 STRING has non-trivial header semantics + LReal/DateTime need typed S7.Net API paths, both land in a follow-up PR when scope demands. InitializeAsync now parses every tag's Address string via S7AddressParser at init time. Bad addresses throw FormatException and flip health to Faulted -- callers can't register a broken driver. The parsed form goes into _parsedByName so Read/Write can consult Size/BitOffset without re-parsing per operation. StatusCode mapping in catch chain: unknown tag name -> BadNodeIdUnknown (0x80340000), unsupported data type -> BadNotSupported (0x803D0000), read-only tag write attempt -> BadNotWritable (0x803B0000), S7.Net PlcException (carries PUT/GET-disabled signal on S7-1200/1500) -> BadDeviceFailure (0x80550000) so operators see a TIA-Portal config problem rather than a transient-fault false flag per driver-specs.md \u00A75, any other runtime exception on read -> BadCommunicationError (0x80050000) to distinguish socket/timeout from tag-level faults. Write generic-exception path stays BadInternalError because write failures can legitimately be driver-side value-range problems. Unit tests (S7DriverReadWriteTests, 3 facts): Initialize_rejects_invalid_tag_address_and_fails_fast -- Tags with a malformed address must throw at InitializeAsync rather than producing a half-healthy driver; ReadAsync_without_initialize_throws_InvalidOperationException + WriteAsync_without_initialize_throws_InvalidOperationException -- pre-init calls hit RequirePlc and throw the uniform 'not initialized' message. Wire-level round-trip coverage (integration test against a live S7-1500 or a mock S7 server) is deferred -- S7.Net doesn't ship an in-process fake and a conformant mock is non-trivial. 53/53 Modbus.Driver.S7.Tests pass (50 parser + 3 read/write). dotnet build clean. 2026-04-19 00:10:41 -04:00
0eab1271be Merge pull request 'Phase 3 PR 63 -- S7AddressParser (DB/M/I/Q/T/C grammar)' (#62) from phase-3-pr63-s7-address-parser into v2 2026-04-19 00:08:27 -04:00
Joseph Doherty
d5034c40f7 Phase 3 PR 63 -- S7AddressParser for DB/M/I/Q/T/C address strings. Adds S7AddressParser + S7ParsedAddress + S7Area + S7Size to the Driver.S7 project. Grammar follows driver-specs.md \u00A75 + Siemens TIA Portal / STEP 7 Classic convention: (1) Data blocks: DB{n}.DB{X|B|W|D}{offset}[.bit] where X=bit (requires .bit suffix 0-7), B=byte, W=word (16-bit), D=dword (32-bit). (2) Merkers: MB{n}, MW{n}, MD{n}, or M{n}.{bit} for bit access. (3) Inputs + Outputs: same {B|W|D} prefix or {n}.{bit} pattern as M. (4) Timers: T{n}. (5) Counters: C{n}. Output is an immutable S7ParsedAddress record struct with Area (DataBlock / Memory / Input / Output / Timer / Counter), DbNumber (only meaningful for DataBlock), Size (Bit / Byte / Word / DWord), ByteOffset (also timer/counter number when Area is Timer/Counter), BitOffset (0-7 for Size=Bit; 0 otherwise). Case-insensitive via ToUpperInvariant, whitespace trimmed on entry. Parse throws FormatException with the offending input echoed in the message; TryParse returns bool for config-validation callers that can't afford exceptions (e.g. Admin UI tag-editor live validation). Strict rejection policy -- 16 garbage cases covered in the theory test: empty/whitespace input, unknown area letter (Z0), DB without number/tail, DB bit size without .bit suffix, bit offset 8+, word/dword with .bit suffix, DB number 0 (must be >=1), non-numeric DB number, unknown size letter (Q), M without offset, M bit access without .bit, bit 8, negative offset, non-digit offset, non-numeric timer. Strict rejection surfaces config errors at driver-init time rather than as BadInternalError on every Read against the bad tag. No driver code wires through yet -- PR 64 is where IReadable/IWritable consume S7ParsedAddress and translate into S7netplus Plc.ReadAsync calls (the S7.Net address grammar is a strict subset of what we accept, and the parser's S7ParsedAddress is the bridge). Unit tests (S7AddressParserTests, 50 facts): parse-valid theories for DB/M/I/Q/T/C covering all size variants + edge bit offsets 0 and 7; case-insensitive + whitespace-trim theory; reject-invalid theory with 16 garbage cases; TryParse round-trip for valid and invalid inputs. 50/50 pass, dotnet build clean. 2026-04-19 00:06:24 -04:00
5e67c49f7c Merge pull request 'Phase 3 PR 62 -- Siemens S7 native driver project scaffold' (#61) from phase-3-pr62-s7-driver-scaffold into v2 2026-04-19 00:05:17 -04:00
Joseph Doherty
0575280a3b Phase 3 PR 62 -- Siemens S7 native driver project scaffold (S7comm via S7netplus). First non-Modbus in-process driver. Creates src/ZB.MOM.WW.OtOpcUa.Driver.S7 (.NET 10, x64 -- S7netplus is managed, no bitness constraint like MXAccess) + tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests + slnx entries. Depends on S7netplus 0.20.0 which is the latest version on NuGet resolvable in this cache (0.21.0 per driver-specs.md is not yet published; 0.20.0 covers the same Plc+CpuType+ReadAsync surface). S7DriverOptions captures the connection settings documented in driver-specs.md \u00A75: Host, Port (default 102 ISO-on-TCP), CpuType (default S71500 per most-common deployment), Rack=0, Slot=0 (S7-1200/1500 onboard PN convention; S7-300/400 operators must override to slot 2 or 3), Timeout=5s, Tags list + Probe settings with default MW0 probe address. S7TagDefinition uses S7.Net-style address strings (DB1.DBW0, M0.0, I0.0, QD4) with an S7DataType enum (Bool, Byte, Int16, UInt16, Int32, UInt32, Int64, UInt64, Float32, Float64, String, DateTime -- the full type matrix from the spec); StringLength defaults to 254 (S7 STRING max). S7Driver implements the IDriver-only subset per the PR plan: InitializeAsync opens a managed Plc with the configured CpuType + Host + Rack + Slot, pins WriteTimeout / ReadTimeout on the underlying TcpClient, awaits Plc.OpenAsync with a linked CTS bounded by Options.Timeout so the ISO handshake itself respects the configured bound; health transitions Unknown -> Initializing -> Healthy on success or Unknown -> Initializing -> Faulted on handshake failure, with a best-effort Plc.Close() on the faulted path so retries don't leak the TcpClient. ShutdownAsync closes the Plc and flips health back to Unknown. DisposeAsync routes through ShutdownAsync + disposes the SemaphoreSlim. Internal Gate + Plc accessors are exposed to the test project (InternalsVisibleTo) so PRs 63-65 can stack read/write/subscribe on the same serialization semaphore per the S7netplus documented 'one Plc per PLC, SemaphoreSlim-serialized' pattern. ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe are all deliberately omitted from this PR -- they depend on the S7AddressParser (PR 63) and land sequenced in PRs 64-65. Unit tests (S7DriverScaffoldTests, 5 facts): default options target S7-1500 / port 102 / slot 0, default probe interval 5s, tag defaults to writable with StringLength 254, driver reports DriverType=S7 + Unknown health pre-init, Initialize against RFC-5737 reserved IP 192.0.2.1 with 250ms timeout transitions to Faulted and throws (tests the connect-failure path doesn't leave the driver in an ambiguous state). 5/5 pass. dotnet build ZB.MOM.WW.OtOpcUa.slnx: 0 errors. No regression in Modbus / Galaxy suites. PR 63 ships S7AddressParser next, PR 64 wires IReadable/IWritable over S7netplus, PR 65 adds discovery + polling-overlay subscribe + probe. 2026-04-19 00:03:09 -04:00
8150177296 Merge pull request 'Phase 2 PR 61 -- Close V1_ARCHIVE_STATUS.md: Streams D + E done' (#60) from phase-2-pr61-scrub-v1-archive-residue into v2 2026-04-18 23:22:58 -04:00
Joseph Doherty
56d8af8bdb Phase 2 PR 61 -- Close V1_ARCHIVE_STATUS.md; Phase 2 Streams D + E done. Purely a documentation-closure PR. The v1 archive deletion itself happened across earlier PRs: PR 2 on phase-2-stream-d archive-marked the four v1 projects (IsTestProject=false so dotnet test slnx bypassed them); Phase 3 PR 18 deleted the archived project source trees. What remained on disk was stale bin/obj residue from pre-deletion builds -- git never tracked those, so removing them from the working tree is cosmetic only (no source-file diff in this PR). What this PR actually changes: V1_ARCHIVE_STATUS.md is rewritten from 'Deletion plan (Phase 2 PR 3)' pre-work prose to a CLOSED retrospective that (a) lists all five v1 directories as deleted with check-marks (src/OtOpcUa.Host, src/Historian.Aveva, tests/Historian.Aveva.Tests, tests/Tests.v1Archive, tests/IntegrationTests), (b) names the parity-bar tests that now fill the role the 494 v1 tests originally held (Driver.Galaxy.E2E cross-FX subprocess parity + stability-findings regression, per-component *.Tests projects, Driver.Modbus.IntegrationTests, LiveStack/ smoke tests), and (c) gives the closure timeline connecting PR 2 -> Phase 3 PR 18 -> this PR 61. Also added the Modbus TCP driver family as parity coverage that didn't exist in v1 (DL205 + S7-1500 + Mitsubishi MELSEC via pymodbus sim). Stream D (retire legacy Host) has been effectively done since Phase 3 PR 18; Stream E (parity validation) is done since PR 2 landed the Driver.Galaxy.E2E project with HostSubprocessParityTests + HierarchyParityTests + StabilityFindingsRegressionTests. This PR exists to definitively close the two pending Phase 2 tasks on the task list and give future-me (or anyone picking up Phase 2 retrospectives) a single 'what actually happened' doc instead of a 'what we plan to do' prose that didn't match reality. dotnet build ZB.MOM.WW.OtOpcUa.slnx: 0 errors, 200 warnings (all xunit1051 cancellation-token analyzer advisories, unchanged from v2 tip). No test regressions -- no source code changed. 2026-04-18 23:20:54 -04:00
be8261a4ac Merge pull request 'Phase 3 PR 60 -- Mitsubishi MELSEC quirk integration tests' (#59) from phase-3-pr60-mitsubishi-quirk-tests into v2 2026-04-18 23:10:36 -04:00
65de2b4a09 Merge pull request 'Phase 3 PR 59 -- MelsecAddress helper with family selector (hex vs octal X/Y)' (#58) from phase-3-pr59-melsec-address-helper into v2 2026-04-18 23:10:29 -04:00
fccb566a30 Merge pull request 'Phase 3 PR 58 -- Mitsubishi MELSEC pymodbus profile + smoke' (#57) from phase-3-pr58-mitsubishi-sim-profile into v2 2026-04-18 23:10:21 -04:00
9ccc7338b8 Merge pull request 'Phase 3 PR 57 -- S7 byte-order + fingerprint integration tests' (#56) from phase-3-pr57-s7-quirk-tests into v2 2026-04-18 23:10:14 -04:00
e33783e042 Merge pull request 'Phase 3 PR 56 -- Siemens S7-1500 pymodbus profile + smoke' (#55) from phase-3-pr56-s7-sim-profile into v2 2026-04-18 23:10:07 -04:00
Joseph Doherty
a44fc7a610 Phase 3 PR 60 -- Mitsubishi MELSEC quirk integration tests against mitsubishi pymodbus profile. Seven facts in MitsubishiQuirkTests covering the quirks documented in docs/v2/mitsubishi.md that are testable end-to-end via pymodbus: (1) Mitsubishi_D0_fingerprint_reads_0x1234 -- MELSEC operators reserve D0 as a fingerprint word so Modbus clients can verify they're hitting the right Device Assignment block; test reads HR[0]=0x1234 via DRegisterToHolding('D0') helper. (2) Mitsubishi_Float32_CDAB_decodes_1_5f_from_D100 -- reads HR[100..101] with WordSwap AND BigEndian; asserts WordSwap==1.5f AND BigEndian!=1.5f, proving (a) MELSEC uses CDAB default same as DL260, (b) opposite of S7 ABCD, (c) driver flag is not a no-op. (3) Mitsubishi_D10_is_binary_not_BCD -- reads HR[10]=0x04D2 as Int16 and asserts value 1234 (binary decode), contrasting with DL205's BCD-by-default convention. (4) Mitsubishi_D10_as_BCD_throws_because_nibble_is_non_decimal -- reads same HR[10] as Bcd16 and asserts StatusCode != 0 because nibble 0xD fails BCD validation; proves the BCD decoder fails loud when the tag config is wrong rather than silently returning garbage. (5) Mitsubishi_QLiQR_X210_hex_maps_to_DI_528_reads_ON -- reads FC02 at the MelsecAddress.XInputToDiscrete('X210', Q_L_iQR)-resolved address (=528 decimal) and asserts ON; proves the hex-parsing path end-to-end. (6) Mitsubishi_family_trap_X20_differs_on_Q_vs_FX -- unit-level proof in the integration file so the headline family trap is visible to anyone filtering by Device=Mitsubishi. (7) Mitsubishi_M512_maps_to_coil_512_reads_ON -- reads FC01 at MRelayToCoil('M512')=512 (decimal) and asserts ON; proves the decimal M-relay path. Test fixture pattern: single MitsubishiQuirkTests class with a shared ShouldRun + NewDriverAsync helper rather than per-quirk classes (contrast with DL205's per-quirk splits). MELSEC per-model differentiation is handled by MelsecFamily enum on the helper rather than per-PR -- so one quirk file + one family enum covers Q/L/iQ-R/FX/iQ-F, and a new PLC family just adds an enum case instead of a new test class. 8/8 Mitsubishi integration tests pass (1 smoke + 7 quirk). 176/176 Modbus.Tests unit suite still green. S7 + DL205 integration tests can be run against their respective profiles by swapping MODBUS_SIM_PROFILE and restarting the pymodbus sim -- each family gates on its profile env var so no cross-family test pollution. 2026-04-18 23:07:00 -04:00
Joseph Doherty
d4c1873998 Phase 3 PR 59 -- MelsecAddress helper for MELSEC X/Y hex-vs-octal family trap + D/M bank bases. Adds MelsecAddress static class with XInputToDiscrete, YOutputToCoil, MRelayToCoil, DRegisterToHolding helpers and a MelsecFamily enum {Q_L_iQR, F_iQF} that drives whether X/Y addresses are parsed as hex (Q-series convention) or octal (FX-series convention). This is the #1 MELSEC driver bug source per docs/v2/mitsubishi.md: the string 'X20' on a MELSEC-Q means DI 32 (hex 0x20) while the same string on an FX3U means DI 16 (octal 0o20). The helper forces the caller to name the family explicitly; no 'sensible default' because wrong defaults just move the bug. Key design decisions: (1) Family is an enum argument, not a helper-level static-selector, because real deployments have BOTH Q-series and FX-series PLCs on the same gateway -- one driver instance per device means family must be per-tag, not per-driver. (2) Bank base is a ushort argument defaulting to 0. Real QJ71MT91/LJ71MT91 assignment blocks commonly place X at DI 8192+, Y at coil 8192+, etc. to leave the low-address range for D-registers; the helper takes the site's configured base as runtime config rather than a compile-time constant. Matches the 'driver opt-in per tag' pattern DirectLogicAddress established for DL260. (3) M-relay and D-register are DECIMAL on every MELSEC family -- docs explicitly; the MELSEC confusion is only about X/Y, not about data registers or internal relays. Helpers reject non-numeric M/D addresses and honor bank bases the same way. (4) Parser walks digits manually for both hex and octal (instead of int.Parse with NumberStyles) so non-hex / non-octal characters give a clear ArgumentException with the offending char + family name. Prevents a subtle class of bugs where int.Parse('X20', Hex) silently returns 32 even for F_iQF callers. Unit tests (MelsecAddressTests, 34 facts): XInputToDiscrete_QLiQR_parses_hex theory (X0, X9, XA, XF, X10, X20, X1FF + lowercase); XInputToDiscrete_FiQF_parses_octal theory (X0, X7, X10, X20, X777); YOutputToCoil equivalents; Same_address_string_decodes_differently_between_families (the headline trap, X20 => 32 on Q vs 16 on FX); reject-non-octal / reject-non-hex / reject-empty / overflow facts; honors-bank-base for X and M and D. 176/176 Modbus.Tests pass (143 prior + 34 new Melsec). No driver core changes -- this is purely a new helper class in the Driver.Modbus project. PR 60 wires it into integration tests against the mitsubishi pymodbus profile. 2026-04-18 23:04:52 -04:00
Joseph Doherty
f52b7d8979 Phase 3 PR 58 -- Mitsubishi MELSEC pymodbus profile + smoke integration test. Adds tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/mitsubishi.json modelling a representative MELSEC Modbus Device Assignment block: D0..D1023 -> HR[0..1023], M-relay marker at coil 512 (cell 32) and X-input marker at DI 528 (cell 33). Covers the canonical MELSEC quirks from docs/v2/mitsubishi.md: D0 fingerprint at HR[0]=0x1234 so clients can verify the assignment parameter block is in effect, scratch HR 200..209 mirroring dl205/s7_1500/standard scratch range for uniform smoke tests, Float32 1.5f at HR[100..101] in CDAB word order (HR[100]=0, HR[101]=0x3FC0) -- same as DL260, OPPOSITE of S7 ABCD, confirms MELSEC-family driver profile default must be ByteOrder.WordSwap. Int32 0x12345678 CDAB at HR[300..301]. D10 = binary 1234 (0x04D2) proves MELSEC is BINARY-by-default (opposite of DL205 BCD-by-default quirk) -- reading D10 with Bcd16 data type would throw InvalidDataException on nibble 0xD. M-relay marker cell moved to address 32 (coil 512) to avoid shared-block collision with D0 uint16 marker at cell 0; pymodbus shared-blocks=true semantics allow only one type per cell index, so Modbus-coil-0 can't coexist with Modbus-HR-0 on the same sim. Same pattern we applied to dl205 profile (X-input bank at cell 1, not cell 0, to coexist with V0 marker). Adds Mitsubishi/ test directory with MitsubishiProfile.cs (SmokeHoldingRegister=200, SmokeHoldingValue=7890, BuildOptions with probe-disabled + 2s timeout) and MitsubishiSmokeTests.cs (Mitsubishi_roundtrip_write_then_read_of_holding_register single fact that writes 7890 at HR[200] then reads back, gated on MODBUS_SIM_PROFILE=mitsubishi). csproj copies Mitsubishi/** as PreserveNewest. Per-model differences (FX5U firmware gate, QJ71MT91 FC22/23 absence, FX/iQ-F octal vs Q/L/iQ-R hex X-addressing) are handled in the MelsecAddress helper (PR 59) + per-model test classes (PR 60). Verified: smoke 1/1 passes against live mitsubishi sim. Prior S7 tests 4/4 still green when swapped back. Modbus.Tests unit suite 143/143. 2026-04-18 23:02:29 -04:00
Joseph Doherty
b54724a812 Phase 3 PR 57 -- S7 byte-order + fingerprint integration tests against s7_1500 pymodbus profile. Three facts in new S7_ByteOrderTests class: (1) S7_Float32_ABCD_decodes_1_5f_from_HR100 reads HR[100..101] with ModbusByteOrder.BigEndian AND with WordSwap on the same wire bytes; asserts BigEndian==1.5f AND WordSwap!=1.5f -- proving both that Siemens S7 stores Float32 in ABCD word order (opposite of DL260 CDAB) and that the ByteOrder flag is not a no-op on the same wire buffer. (2) S7_Int32_ABCD_decodes_0x12345678_from_HR300 reads HR[300]=0x1234 + HR[301]=0x5678 with BigEndian and asserts the reassembled Int32 = 0x12345678; documents the contrast with DL260 CDAB Int32 encoding. (3) S7_DB1_fingerprint_marker_at_HR0_reads_0xABCD reads HR[0]=0xABCD -- real MB_SERVER deployments reserve DB1.DBW0 as a fingerprint so clients can verify they're pointing at the right DB, protecting against typos in the MB_SERVER.MB_HOLD_REG.DB_number parameter. No driver code changes -- the ByteOrder.BigEndian path has existed since PR 24; this PR exists to lock in the S7-specific semantics at the integration level so future refactors of NormalizeWordOrder can't silently break S7. All 3 tests gate on MODBUS_SIM_PROFILE=s7_1500 so they skip cleanly against dl205 or standard profiles. Verified end-to-end: 4/4 S7 integration tests pass (1 smoke from PR 56 + 3 new). No regression in driver unit tests. Per the per-quirk-PR plan: the S7 quirks NOT testable via pymodbus sim (MB_SERVER STATUS 0x8383 optimized-DB behavior, port-per-connection semantics, CP 343-1 Lean license rejection, STOP-mode non-determinism) remain in docs/v2/s7.md as design guidance for driver users rather than automated tests -- they're TIA-Portal-side or CP-hardware-side behaviors that pymodbus cannot reproduce without custom Python actions. 2026-04-18 22:58:44 -04:00
Joseph Doherty
10c724b5b6 Phase 3 PR 56 -- Siemens S7-1500 pymodbus profile + smoke integration test. Adds tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/Pymodbus/s7_1500.json modelling the SIMATIC S7-1500 + MB_SERVER default deployment documented in docs/v2/s7.md: DB1.DBW0 = 0xABCD fingerprint marker (operators reserve this so clients can verify they're talking to the right DB), scratch HR range 200..209 for write-roundtrip tests mirroring dl205.json + standard.json, Float32 1.5f at HR[100..101] in ABCD word order (high word first -- OPPOSITE of DL260 CDAB), Int32 0x12345678 at HR[300..301] in ABCD. Also seeds a coil at bit-addr 400 (= cell 25 bit 0) and a discrete input at bit-addr 500 (= cell 31 bit 0) so future S7-specific tests for FC01/FC02 have stable markers. shared blocks=true to match the proven dl205.json pattern (pymodbus's bits/uint16 cells coexist cleanly when addresses don't collide). Write list references cells (0, 25, 100-101, 200-209, 300-301), not bit addresses -- pymodbus's write-range entries are cell-indexed, not bit-indexed. Adds tests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests/S7/ directory with S7_1500Profile.cs (mirrors DL205Profile pattern: SmokeHoldingRegister=200, SmokeHoldingValue=4321, BuildOptions tags + probe-disabled + 2s timeout) and S7_1500SmokeTests.cs (single fact S7_1500_roundtrip_write_then_read_of_holding_register that writes SmokeHoldingValue then reads it back, asserting both write status 0 and read status 0 + value equality). Gates on MODBUS_SIM_PROFILE=s7_1500 so the test skips cleanly against other profiles. csproj updated to copy S7/** to test output as PreserveNewest (pattern matching DL205/**). Pymodbus/serve.ps1 ValidateSet extended from {standard,dl205} to {standard,dl205,s7_1500,mitsubishi} -- mitsubishi.json lands in PR 58 but the validator slot is claimed now so the serve.ps1 diff is one line in this PR and zero lines in future PRs. Verified end-to-end: smoke test 1/1 passes against the running pymodbus s7_1500 profile (localhost:5020 FC06 write of 4321 at HR[200] + FC03 read back). 143/143 Modbus.Tests pass, no regression in driver code because this PR is purely test-asset. Per-quirk S7 integration tests (ABCD word order default, FC23 IllegalFunction, MB_SERVER STATUS 0x8383 behaviour, port-per-connection semantics) land in PR 57+. 2026-04-18 22:57:03 -04:00
8c89d603e8 Merge pull request 'Phase 3 PR 55 -- Mitsubishi MELSEC Modbus TCP quirks research doc' (#54) from phase-3-pr55-mitsubishi-research-doc into v2 2026-04-18 22:54:09 -04:00
299bd4a932 Merge pull request 'Phase 3 PR 54 -- Siemens S7 Modbus TCP quirks research doc' (#53) from phase-3-pr54-s7-research-doc into v2 2026-04-18 22:54:02 -04:00
Joseph Doherty
c506ea298a Phase 3 PR 55 -- Mitsubishi MELSEC Modbus TCP quirks research document. 451-line doc at docs/v2/mitsubishi.md mirroring the docs/v2/dl205.md template for the MELSEC family (Q-series + QJ71MT91, L-series + LJ71MT91, iQ-R + RJ71EN71, iQ-R built-in Ethernet, iQ-F FX5U built-in, FX3U + FX3U-ENET / FX3U-ENET-P502, FX3GE built-in). Like Siemens S7, MELSEC Modbus is a patchwork of per-site-configured add-on modules rather than a fixed firmware stack, but the MELSEC-specific traps are different enough to warrant their own document. Key findings worth flagging for the PR 58+ implementation track: (1) MODULE NAMING TRAP -- QJ71MB91 is SERIAL RTU, not TCP. The Q-series TCP module is QJ71MT91. Driver docs + config UI should surface this clearly because the confusion costs operators hours when they try to connect to an RS-232 module via Ethernet. (2) NO CANONICAL MAPPING -- every MELSEC Modbus site has a unique 'Modbus Device Assignment Parameter' block of up to 16 assignments (each binding a MELSEC device range like D0..D1023 to a Modbus-address range); the driver must treat the mapping as runtime config, not device-family profile. (3) X/Y BASE DEPENDS ON FAMILY -- Q/L/iQ-R use HEX notation for X/Y (X20 = decimal 32), FX/iQ-F use OCTAL (X20 = decimal 16, same as DL260); iQ-F has a GX Works3 project toggle that can flip this. Single biggest off-by-N source in MELSEC driver code -- driver address helper must take a family selector. (4) Word order CDAB across Q/L/iQ-R/iQ-F by default (CPU-level, not module-level) -- no user-configurable swap on the server side. FX5U's SWAP instruction is for CLIENT mode only. Driver Mitsubishi profile default must be ByteOrder.WordSwap, matching DL260 but OPPOSITE of Siemens S7. (5) D-registers are BINARY by default (opposite of DL205's BCD-by-default). FNC 18 BCD / FNC 19 BIN instructions confirm binary-by-default in the ladder. Caller must explicitly opt-in to Bcd16/Bcd32 tags when the ladder stores BCD, same pattern as DL205 but the default is inverted. (6) FX5U FIRMWARE GATE -- needs firmware >= 1.060 for native Modbus TCP server; older firmware is client-only. Surface a clear capability error on connect. (7) FX3U PORT 502 SPLIT -- the standard FX3U-ENET cannot bind port 502 (lower port range restricted on the firmware); only FX3U-ENET-P502 can. FX3U-ENET-ADP has no Modbus at all and is a common operator mis-purchase -- driver should surface 'module does not support Modbus' as a distinct error, not 'connection refused'. (8) QJ71MT91 does NOT support FC22 (Mask Write) or FC23 (Read-Write Multiple). iQ-R and iQ-F do. Driver bulk-read optimization must gate on module capability. (9) MAX CONNECTIONS -- 16 simultaneous on Q/L/iQ-R, 8 on FX5U and FX3U-ENET. (10) STOP-mode writes -- configurable on Q/L/iQ-R/iQ-F (default = accept writes even in STOP), always rejected with exception 04 on FX3U-ENET. Per-model test differentiation section names the tests Mitsubishi_QJ71MT91_*, Mitsubishi_FX5U_*, Mitsubishi_FX3U_ENET_*, with a shared Mitsubishi_Common_* fixture for CDAB-word-order + binary-not-BCD + standard-exception-codes tests. 17 cited references including primary Mitsubishi manuals (SH-080446 for QJ71MT91, JY997D56101 for FX5, SH-081259 for iQ-R Ethernet, JY997D18101 for FX3U-ENET) plus Ignition / Kepware / Fernhill / HMS third-party driver release notes. Three unconfirmed rumours flagged explicitly: iQ-R RJ71EN71 early firmware rumoured ABCD word order (no primary source), QJ71MT91 firmware < 2010-05 FC15 odd-byte-count truncation (forum report only), FX3U-ENET firmware < 1.14 out-of-order TxId echoes under load (unreproducible on bench). Pure documentation PR -- no code, no tests. Per-quirk implementation lands in PRs 58+. Research conducted 2026-04-18. 2026-04-18 22:51:28 -04:00
Joseph Doherty
9e2b5b330f Phase 3 PR 54 -- Siemens S7 Modbus TCP quirks research document. 485-line doc at docs/v2/s7.md mirroring the docs/v2/dl205.md template for the Siemens SIMATIC S7 family (S7-1200 / S7-1500 / S7-300 / S7-400 / ET 200SP / CP 343-1 / CP 443-1 / CP 343-1 Lean / MODBUSPN). Siemens S7 is fundamentally different from DL260: there is no fixed Modbus memory map baked into firmware -- every deployment runs MB_SERVER (S7-1200/1500/ET 200SP), MODBUSCP (S7-300/400 + CP), or MODBUSPN (S7-300/400 PN) library blocks wired up to user DBs via the MB_HOLD_REG / ADDR parameters. The driver's job is therefore to handle per-site CONFIG rather than per-family QUIRKS, and the doc makes that explicit. Key findings worth flagging for the PR 56+ implementation track: (1) S7 has no fixed memory map -- must accept per-site DriverConfig, cannot assume vendor-standard layout. (2) MB_SERVER requires NON-optimized DBs in TIA Portal; optimized DBs cause the library to return STATUS 0x8383 on every access -- the single most common S7 Modbus deployment bug in the field. (3) Word order is ABCD by default (big-endian bytes + big-endian words) across all Siemens S7 Modbus paths, which is the OPPOSITE of DL260 CDAB -- the Modbus driver's S7 profile default must be ByteOrder.BigEndian, not WordSwap. (4) MB_SERVER listens on ONE port per FB instance; multi-client support requires running MB_SERVER on 502 / 503 / 504 / ... simultaneously -- most clients assume port 502 multiplexes, which is wrong on S7. (5) CP 343-1 Lean is SERVER-ONLY and requires the separate 2XV9450-1MB00 MODBUS TCP CP library license; client mode calls return immediate error on Lean. (6) MB_SERVER does NOT filter Unit ID, accepts any value. Means the driver can't use Unit ID to detect 'direct vs gateway' topology. (7) FC23 Read-Write Multiple, FC22 Mask Write, FC20/21 File Records, FC43 Device Identification all return exception 01 Illegal Function on every S7 variant -- the driver MUST NOT attempt bulk-read optimisation via FC23 when talking to S7. (8) STOP-mode read/write behaviour is non-deterministic across firmware bands: reads may return cached data (library internal buffer), writes may succeed-silently or return exception 04 depending on CPU firmware version -- flagged as 'driver treats both as unavailable, do not distinguish'. Unconfirmed rumours flagged separately: 'V2.0+ reverses float byte order' claim (cited but not reproduced), STOP-mode caching location (folklore, no primary source). Per-model test differentiation section names the tests as S7_<model>_<behavior> matching the DL205 template convention (e.g. S7_1200_MB_SERVER_requires_non_optimized_DB, S7_343_1_Lean_rejects_client_mode, S7_FC23_returns_IllegalFunction). 31 cited references across the Siemens Industry Online Support entry-ID system (68011496 for MB_SERVER FAQ, etc.), TIA Portal library manuals, and three third-party driver vendor release notes (Kepware, Ignition, FactoryTalk). This is a pure documentation PR -- no code, no tests, no csproj changes. Per-quirk implementation lands in PRs 56+. Research conducted 2026-04-18 against latest publicly-available Siemens documentation; STOP-mode behaviour and MB_SERVER versioning specifically cross-checked against Siemens forum answers from 2024-2025. 2026-04-18 22:50:51 -04:00
d5c6280333 Merge pull request 'Phase 3 PR 53 -- Transport reconnect-on-drop + SO_KEEPALIVE (DL260 no-keepalive quirk)' (#52) from phase-3-pr53-dl205-reconnect into v2 2026-04-18 22:35:40 -04:00
476ce9b7c5 Merge pull request 'Phase 3 PR 52 -- Modbus exception-code -> OPC UA StatusCode translation' (#51) from phase-3-pr52-dl205-exception-codes into v2 2026-04-18 22:35:33 -04:00
954bf55d28 Merge pull request 'Phase 3 PR 51 -- DL260 X-input FC02 discrete-input mapping end-to-end test' (#50) from phase-3-pr51-dl205-xinput into v2 2026-04-18 22:35:25 -04:00
9fb3cf7512 Merge pull request 'Phase 3 PR 50 -- DL260 bit-memory helpers (Y/C/X/SP) + coil integration tests' (#49) from phase-3-pr50-dl205-coil-mapping into v2 2026-04-18 22:35:18 -04:00
Joseph Doherty
793c787315 Phase 3 PR 53 -- Transport reconnect-on-drop + SO_KEEPALIVE for DL205 no-keepalive quirk. AutomationDirect H2-ECOM100 does NOT send TCP keepalives per docs/v2/dl205.md behavioral-oddities section -- any NAT/firewall device between the gateway and the PLC can silently close an idle socket after 2-5 minutes of inactivity. The PLC itself never notices and the first SendAsync after the drop would previously surface as IOException / EndOfStreamException / SocketException to the caller even though the PLC is perfectly healthy. PR 53 makes ModbusTcpTransport survive mid-session socket drops: SendAsync wraps the previous body as SendOnceAsync; on the first attempt, if the failure is a socket-layer error (IOException, SocketException, EndOfStreamException, ObjectDisposedException) AND autoReconnect is enabled (default true), the transport tears down the dead socket, calls ConnectAsync to re-establish, and resends the PDU exactly once. Deliberately single-retry -- further failures propagate so the driver health surface reflects the real state, no masking a dead PLC. Protocol-layer failures (e.g. ModbusException with exception code 02) are specifically NOT caught by the reconnect path -- they would just come back with the same exception code after the reconnect, so retrying is wasted wire time. Socket-level vs protocol-level is a discriminator inside IsSocketLevelFailure. Also enables SO_KEEPALIVE on the TcpClient with aggressive timing: TcpKeepAliveTime=30s, TcpKeepAliveInterval=10s, TcpKeepAliveRetryCount=3. Total time-to-detect-dead-socket = 30 + 10*3 = 60s, vs the Windows default 2-hour idle + 9 retries = 2h40min. Best-effort: older OSes that don't expose the fine-grained keepalive knobs silently skip them (catch {}). New ModbusDriverOptions.AutoReconnect bool (default true) threads through to the default transport factory in ModbusDriver -- callers wanting the old 'fail loud on drop' behavior can set AutoReconnect=false, or use a custom transportFactory that ignores the option. Unit tests: ModbusTcpReconnectTests boots a FlakeyModbusServer in-process (real TcpListener on loopback) that serves one valid FC03 response then forcibly shuts down the socket. Transport_recovers_from_mid_session_drop_and_retries_successfully issues two consecutive SendAsync calls and asserts both return valid PDUs -- the second must trigger the reconnect path transparently. Transport_without_AutoReconnect_propagates_drop_to_caller asserts the legacy behavior when the opt-out is taken. Validates real socket semantics rather than mocked exceptions. 142/142 Modbus.Tests pass (113 prior + 2 mapper + 2 reconnect + 25 accumulated across PRs 45-52); 11/11 DL205 integration tests still pass with MODBUS_SIM_PROFILE=dl205 -- no regression from the transport change. 2026-04-18 22:32:13 -04:00
Joseph Doherty
cde018aec1 Phase 3 PR 52 -- Modbus exception-code -> OPC UA StatusCode translation. Before this PR every server-side Modbus exception AND every transport-layer failure collapsed to BadInternalError (0x80020000) in the driver's Read/Write results, making field diagnosis 'is this a tag misconfig or a driver bug?' impossible from the OPC UA client side. PR 52 adds a MapModbusExceptionToStatus helper that translates per spec: 01 Illegal Function -> BadNotSupported (0x803D0000); 02 Illegal Data Address -> BadOutOfRange (0x803C0000); 03 Illegal Data Value -> BadOutOfRange; 04 Server Failure -> BadDeviceFailure (0x80550000); 05/06 Acknowledge/Busy -> BadDeviceFailure; 0A/0B Gateway -> BadCommunicationError (0x80050000); unknown -> BadInternalError fallback. Non-Modbus failures (socket drop, timeout, malformed frame) in ReadAsync are now distinguished from tag-level faults: they map to BadCommunicationError so operators check network/PLC reachability rather than tag definitions. Why per-DL205: docs/v2/dl205.md documents DL205/DL260 returning only codes 01-04 with specific triggers -- exception 04 specifically means 'CPU in PROGRAM mode during a protected write', which is operator-recoverable by switching the CPU to RUN; surfacing it as BadDeviceFailure (not BadInternalError) makes the fix obvious. Changes in ModbusDriver: Read catch-chain now ModbusException first (-> mapper), generic Exception second (-> BadCommunicationError); Write catch-chain same pattern but generic Exception stays BadInternalError because write failures can legitimately come from EncodeRegister (out-of-range value) which is a driver-layer fault. Unit tests: MapModbusExceptionToStatus theory exercising every code in the table including the 0xFF fallback; Read_surface_exception_02_as_BadOutOfRange with an ExceptionRaisingTransport that forces code 02; Write_surface_exception_04_as_BadDeviceFailure for CPU-mode faults; Read_non_modbus_failure_maps_to_BadCommunicationError with a NonModbusFailureTransport that raises EndOfStreamException. 115/115 Modbus.Tests pass. Integration test: DL205ExceptionCodeTests.DL205_FC03_at_unmapped_register_returns_BadOutOfRange reads HR[16383] which is beyond the seeded uint16 cells on the dl205.json profile; pymodbus returns exception 02 and the driver surfaces BadOutOfRange. 11/11 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205. 2026-04-18 22:28:37 -04:00
Joseph Doherty
9892a0253d Phase 3 PR 51 -- DL260 X-input FC02 discrete-input mapping end-to-end test. Integration test DL205XInputTests reads FC02 at the DirectLogicAddress.XInputToDiscrete-resolved address and asserts two behaviors against the dl205.json pymodbus profile: (1) X20 octal (=decimal 16 = Modbus DI 16) reads ON, proving the helper correctly octal-parses the trailing number and adds it to the 0 base; (2) X21 octal reads OFF (not exception) -- per docs/v2/dl205.md §I/O-mapping, 'reading a non-populated X input returns zero, not an exception' on DL260, because the CPU sizes the discrete-input table to the configured I/O not the installed hardware. Pymodbus models this by returning the default 0 value for any DI bit in the configured 'di size' range that wasn't explicitly seeded, matching real DL260 behaviour. Test uses X20 rather than X0 to sidestep a shared-blocks conflict: pymodbus places FC01/FC02 bit-address 0..15 into cell 0, but cell 0 is already uint16-typed (V0 marker = 0xCAFE) per the register-zero quirk test, and shared-blocks semantics allow only one type per cell. X20 octal = DI 16 lands in cell 1 which is free, so both the V0 quirk AND the X-input quirk can coexist in one profile. dl205.json: bits cell 1 seeded value=9 (bits 0 and 3 set -> X20, X23 octal = ON), write-range extended to include cell 1 (though X-inputs are read-only; the write-range entry is required by pymodbus for ANY cell referenced in a bits section even if only reads are expected -- pymodbus validates write-access uniformly). 10/10 DL205 integration tests pass with MODBUS_SIM_PROFILE=dl205. No driver code changes -- the XInputToDiscrete helper + FC02 read path already landed in PRs 50 and 21 respectively. This PR closes the integration-test gap that docs/v2/dl205.md called out under test name DL205_Xinput_unpopulated_reads_as_zero. 2026-04-18 22:25:13 -04:00
Joseph Doherty
b5464f11ee Phase 3 PR 50 -- DL260 bit-memory address helpers (Y/C/X/SP) + live coil integration tests. Adds four new static helpers to DirectLogicAddress covering every discrete-memory bank on the DL260: YOutputToCoil (Y0=coil 2048), CRelayToCoil (C0=coil 3072), XInputToDiscrete (X0=DI 0), SpecialToDiscrete (SP0=DI 1024). Each helper takes the DirectLOGIC ladder-logic address (e.g. 'Y0', 'Y17', 'C1777') and adds the octal-decoded offset to the bank's Modbus base per the DL260 user manual's I/O-configuration chapter table. Uses the same 'octal-walk + reject 8/9' pattern as UserVMemoryToPdu so misaligned addresses fail loudly with a clear ArgumentException rather than silently hitting the wrong coil. Fixes a pymodbus-config bug surfaced during integration-test validation: dl205.json had bits entries at cell indices 2048 / 3072 / 4000, but pymodbus's ModbusSimulatorContext.validate divides bit addresses by 16 before indexing into the shared cell array -- so Modbus coil 2048 reads cell 128, not cell 2048. The sim was returning Illegal Data Address (exception 02) for every bit read in the Y/C/scratch range. Moved bits entries to cells 128 (Y bank marker = 0b101 for Y0=ON, Y1=OFF, Y2=ON), 192 (C bank marker = 0b101 for C0/C1/C2), 250 (scratch cell covering coils 4000..4015). write list updated to the correct cell addresses. Unit tests: YOutputToCoil theory sweep (Y0->2048, Y1->2049, Y7->2055, Y10->2056 octal-to-decimal, Y17->2063, Y777->2559 top of DL260 Y range), CRelayToCoil theory (C0->3072 through C1777->4095), XInputToDiscrete theory, SpecialToDiscrete theory (with case-insensitive 'SP' prefix). Bit_address_rejects_non_octal_digits (Y8/C9/X18), Bit_address_rejects_empty, accepts_lowercase_prefix, accepts_bare_octal_without_prefix. 48/48 Modbus.Tests pass. Integration tests: DL205CoilMappingTests with three facts -- DL260_Y0_maps_to_coil_2048 (FC01 at Y0 returns ON), DL260_C0_maps_to_coil_3072 (FC01 at C0 returns ON), DL260_scratch_Crelay_supports_write_then_read (FC05 write + FC01 read round-trip at coil 4000 proves the DL-mapped coil bank is fully read/write capable end-to-end). 9/9 DL205 integration tests pass against the pymodbus dl205 profile with MODBUS_SIM_PROFILE=dl205. Caller opts into the helpers per tag the same way as PR 47's V-memory helper -- pass DirectLogicAddress.YOutputToCoil("Y0") as the ModbusTagDefinition Address; no driver-wide DL-family flag. PR 51 adds the X-input read-side integration test (there's nothing to write since X-inputs are FC02 discrete inputs, read-only); PR 52 exception-code translation; PR 53 transport reconnect-on-drop since DL260 doesn't send TCP keepalives. 2026-04-18 22:22:42 -04:00
dae29f14c8 Merge pull request 'Phase 3 PR 49 -- Per-device FC03/FC16 register caps with auto-chunking' (#48) from phase-3-pr49-dl205-fc-caps into v2 2026-04-18 22:13:46 -04:00
f306793e36 Merge pull request 'Phase 3 PR 48 -- DL205 CDAB float word order end-to-end test' (#47) from phase-3-pr48-dl205-cdab-float into v2 2026-04-18 22:13:39 -04:00
9e61873cc0 Merge pull request 'Phase 3 PR 47 -- DL205 V-memory octal-address helper' (#46) from phase-3-pr47-dl205-vmemory into v2 2026-04-18 22:13:32 -04:00
1a60470d4a Merge pull request 'Phase 3 PR 46 -- DL205 BCD decoder' (#45) from phase-3-pr46-dl205-bcd into v2 2026-04-18 22:13:24 -04:00
635f67bb02 Merge pull request 'Phase 3 PR 45 -- DL205 string byte-order quirk' (#44) from phase-3-pr45-dl205-string-byte-order into v2 2026-04-18 22:12:15 -04:00
Joseph Doherty
a3f2f95344 Phase 3 PR 49 -- Per-device FC03/FC16 register caps with auto-chunking. Adds MaxRegistersPerRead (default 125, spec max) + MaxRegistersPerWrite (default 123, spec max) to ModbusDriverOptions. Reads that exceed the cap automatically split into consecutive FC03 requests: the driver dispatches chunks of [cap] regs at incrementing addresses, copies each response into an assembled byte[] buffer, and hands the full payload to DecodeRegister. From the caller's view a 240-char string read against a cap-100 device is still one Read() call returning one string -- the chunking is invisible, the wire shows N requests of cap-sized quantity plus one tail chunk. Writes are NOT auto-chunked. Splitting an FC16 across two transactions would lose atomicity -- mid-split crash leaves half the value written, which is strictly worse than rejecting upfront. Instead, writes exceeding MaxRegistersPerWrite throw InvalidOperationException with a message naming the tag + cap + the caller's escape hatch (shorten StringLength or split into multiple tags). The driver catches the exception internally and surfaces it to IWritable as BadInternalError so the caller pattern stays symmetric with other failure modes. Per-family cap cheat-sheet (documented in xml-doc on the option): Modbus-TCP spec = 125 read / 123 write, AutomationDirect DL205/DL260 = 128 read / 100 write (128 exceeds spec byte-count capacity so in practice 125 is the working ceiling), Mitsubishi Q/FX3U = 64 / 64, Omron CJ/CS = 125 / 123. Not all PLCs reject over-cap requests cleanly -- some drop the connection silently -- so having the cap enforced client-side prevents the hard-to-diagnose 'driver just stopped' failure mode. Unit tests: Read_within_cap_issues_single_FC03_request (control: no unnecessary chunking), Read_above_cap_splits_into_two_FC03_requests (120 regs / cap 100 -> 100+20, asserts exact per-chunk (Address,Quantity) and end-to-end payload continuity starting with register[100] high byte = 'A'), Read_cap_honors_Mitsubishi_lower_cap_of_64 (100 regs / cap 64 -> 64+36), Write_exceeding_cap_throws_instead_of_splitting (110 regs / cap 100 -> status != 0 AND Fc16Requests.Count == 0 to prove nothing was sent), Write_within_cap_proceeds_normally (control: cap honored on short writes too). Tests use a new RecordingTransport that captures the (Address, Quantity) tuple of every FC03/FC16 request so the chunk layout is directly assertable -- the existing FakeTransport does not expose request history. 103/103 Modbus.Tests pass; 6/6 DL205 integration tests still pass against the live pymodbus dl205 profile with MODBUS_SIM_PROFILE=dl205. 2026-04-18 21:58:49 -04:00
48 changed files with 5392 additions and 109 deletions

View File

@@ -9,6 +9,8 @@
<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.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
<Project Path="src/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.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"/>
@@ -26,6 +28,8 @@
<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.Driver.Modbus.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests.csproj"/>
<Project Path="tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.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"/>

1
_p54.json Normal file
View File

@@ -0,0 +1 @@
{"title":"Phase 3 PR 54 -- Siemens S7 Modbus TCP quirks research doc","body":"## Summary\n\nAdds `docs/v2/s7.md` (485 lines) covering Siemens SIMATIC S7 family Modbus TCP behavior. Mirrors the `docs/v2/dl205.md` template for future per-quirk implementation PRs.\n\n## Key findings for the implementation track\n\n- **No fixed memory map** — every S7 Modbus server is user-wired via `MB_SERVER`/`MODBUSCP`/`MODBUSPN` library blocks. Driver must accept per-site config, not assume a vendor layout.\n- **MB_SERVER requires non-optimized DBs** (STATUS `0x8383` if optimized). Most common field bug.\n- **Word order default = ABCD** (opposite of DL260). Driver's S7 profile default must be `ByteOrder.BigEndian`, not `WordSwap`.\n- **One port per MB_SERVER instance** — multi-client requires parallel FBs on 503/504/… Most clients assume port 502 multiplexes (wrong on S7).\n- **CP 343-1 Lean is server-only**, requires the `2XV9450-1MB00` license.\n- **FC20/21/22/23/43 all return Illegal Function** on every S7 variant — driver must not attempt FC23 bulk-read optimization for S7.\n- **STOP-mode behavior non-deterministic** across firmware bands — treat both read/write STOP-mode responses as unavailable.\n\nTwo items flagged as unconfirmed rumour (V2.0+ float byte-order claim, STOP-mode caching location).\n\nNo code, no tests — implementation lands in PRs 56+.\n\n## Test plan\n- [x] Doc renders as markdown\n- [x] 31 citations present\n- [x] Section structure matches dl205.md template","head":"phase-3-pr54-s7-research-doc","base":"v2"}

1
_p55.json Normal file
View File

@@ -0,0 +1 @@
{"title":"Phase 3 PR 55 -- Mitsubishi MELSEC Modbus TCP quirks research doc","body":"## Summary\n\nAdds `docs/v2/mitsubishi.md` (451 lines) covering MELSEC Q/L/iQ-R/iQ-F/FX3U Modbus TCP behavior. Mirrors `docs/v2/dl205.md` template for per-quirk implementation PRs.\n\n## Key findings for the implementation track\n\n- **Module naming trap** — `QJ71MB91` is SERIAL RTU, not TCP. TCP module is `QJ71MT91`. Surface clearly in driver docs.\n- **No canonical mapping** — per-site 'Modbus Device Assignment Parameter' block (up to 16 entries). Treat mapping as runtime config.\n- **X/Y hex vs octal depends on family** — Q/L/iQ-R use HEX (X20 = decimal 32); FX/iQ-F use OCTAL (X20 = decimal 16). Helper must take a family selector.\n- **Word order CDAB default** across all MELSEC families (opposite of Siemens S7). Driver Mitsubishi profile default: `ByteOrder.WordSwap`.\n- **D-registers binary by default** (opposite of DL205's BCD default). Caller opts in to `Bcd16`/`Bcd32` when ladder uses BCD.\n- **FX5U needs firmware ≥ 1.060** for Modbus TCP server — older is client-only.\n- **FX3U-ENET vs FX3U-ENET-P502 vs FX3U-ENET-ADP** — only the middle one binds port 502; the last has no Modbus at all. Common operator mis-purchase.\n- **QJ71MT91 does NOT support FC22 / FC23** — iQ-R / iQ-F do. Bulk-read optimization must gate on capability.\n- **STOP-mode writes configurable** on Q/L/iQ-R/iQ-F (default accept), always rejected on FX3U-ENET.\n\nThree unconfirmed rumours flagged separately.\n\nNo code, no tests — implementation lands in PRs 58+.\n\n## Test plan\n- [x] Doc renders as markdown\n- [x] 17 citations present\n- [x] Per-model test naming matrix included (`Mitsubishi_QJ71MT91_*`, `Mitsubishi_FX5U_*`, `Mitsubishi_FX3U_ENET_*`, shared `Mitsubishi_Common_*`)","head":"phase-3-pr55-mitsubishi-research-doc","base":"v2"}

View File

@@ -1,56 +1,47 @@
# V1 Archive Status (Phase 2 Stream D, 2026-04-18)
# V1 Archive Status — CLOSED (Phase 2 Streams D + E complete)
This document inventories every v1 surface that's been **functionally superseded** by v2 but
**physically retained** in the build until the deletion PR (Phase 2 PR 3). Rationale: cascading
references mean a single deletion is high blast-radius; archive-marking lets the v2 stack ship
on its own merits while the v1 surface stays as parity reference.
> **Status as of 2026-04-18: the v1 archive has been fully removed from the tree.**
> This document is retained as historical record of the Phase 2 Stream D / E closure.
## Archived projects
## Final state
| Path | Status | Replaced by | Build behavior |
|---|---|---|---|
| `src/ZB.MOM.WW.OtOpcUa.Host/` | Archive (executable in build) | `OtOpcUa.Server` + `Driver.Galaxy.Host` + `Driver.Galaxy.Proxy` | Builds; not deployed by v2 install scripts |
| `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` | Archive (plugin in build) | TODO: port into `Driver.Galaxy.Host/Backend/Historian/` (Task B.1.h follow-up) | Builds; loaded only by archived Host |
| `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/` | Archive | `Driver.Galaxy.E2E` + per-component test projects | `<IsTestProject>false</IsTestProject>``dotnet test slnx` skips |
| `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` | Archive | `Driver.Galaxy.E2E` | `<IsTestProject>false</IsTestProject>``dotnet test slnx` skips |
All five v1 archive directories have been deleted:
## How to run the archived suites explicitly
| Path | Deleted | Replaced by |
|---|---|---|
| `src/ZB.MOM.WW.OtOpcUa.Host/` | ✅ | `OtOpcUa.Server` + `Driver.Galaxy.Host` + `Driver.Galaxy.Proxy` |
| `src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/` | ✅ | `Driver.Galaxy.Host/Backend/Historian/` (ported in Phase 3 PRs 51-55) |
| `tests/ZB.MOM.WW.OtOpcUa.Historian.Aveva.Tests/` | ✅ | `Driver.Galaxy.Host.Tests/Historian/` |
| `tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/` | ✅ | Per-component `*.Tests` projects + `Driver.Galaxy.E2E` |
| `tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/` | ✅ | `Driver.Galaxy.E2E` + `Driver.Modbus.IntegrationTests` |
```powershell
# v1 unit tests (494):
dotnet test tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive
## Closure timeline
# v1 integration tests (6):
dotnet test tests/ZB.MOM.WW.OtOpcUa.IntegrationTests
```
- **PR 2 (2026-04-18, phase-2-stream-d)** — archive-marked the four v1 projects with
`<IsTestProject>false</IsTestProject>` so solution builds and `dotnet test slnx` bypassed
them. Capture: `docs/v2/implementation/exit-gate-phase-2-final.md`.
- **Phase 3 PR 18 (2026-04-18)** — deleted the archived project source trees. Leftover
`bin/` and `obj/` residue remained on disk from pre-deletion builds.
- **Phase 2 PR 61 (2026-04-18, this closure PR)** — scrubbed the empty residue directories
and confirmed `dotnet build ZB.MOM.WW.OtOpcUa.slnx` clean with 0 errors.
Both still pass on this dev box — they're the parity reference for Phase 2 PR 3's deletion
decision.
## Parity validation (Stream E)
## Deletion plan (Phase 2 PR 3)
The original 494 v1 tests + 6 v1 integration tests are **not** preserved in the v2 branch.
Their parity-bar role is now filled by:
Pre-conditions:
- [ ] `Driver.Galaxy.E2E` test count covers the v1 IntegrationTests' 6 integration scenarios
at minimum (currently 7 tests; expand as needed)
- [ ] `Driver.Galaxy.Host/Backend/Historian/` ports the Wonderware Historian plugin
so `MxAccessGalaxyBackend.HistoryReadAsync` returns real data (Task B.1.h)
- [ ] Operator review on a separate PR — destructive change
Steps:
1. `git rm -r src/ZB.MOM.WW.OtOpcUa.Host/`
2. `git rm -r src/ZB.MOM.WW.OtOpcUa.Historian.Aveva/`
(or move it under Driver.Galaxy.Host first if the lift is part of the same PR)
3. `git rm -r tests/ZB.MOM.WW.OtOpcUa.Tests.v1Archive/`
4. `git rm -r tests/ZB.MOM.WW.OtOpcUa.IntegrationTests/`
5. Edit `ZB.MOM.WW.OtOpcUa.slnx` — remove the four project lines
6. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` → confirm clean
7. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` → confirm 470+ pass / 1 baseline (or whatever the
current count is plus any new E2E coverage)
8. Commit: "Phase 2 Stream D — delete v1 archive (Host + Historian.Aveva + v1Tests + IntegrationTests)"
9. PR 3 against `v2`, link this doc + exit-gate-phase-2-final.md
10. One reviewer signoff
- `Driver.Galaxy.E2E` — cross-FX subprocess parity (spawns the net48 x86 Galaxy.Host.exe
+ connects via real named pipe, exercises every `IDriver` capability through the
supervisor). Stability-findings regression tests (4 × 2026-04-13 findings) live here.
- Per-component `*.Tests` projects — cover the code that moved out of the monolith into
discrete v2 projects. Running `dotnet test ZB.MOM.WW.OtOpcUa.slnx` executes all of them
as one solution-level gate.
- `Driver.Modbus.IntegrationTests` — adds Modbus TCP driver coverage that didn't exist in
v1 (DL205, S7-1500, Mitsubishi MELSEC via pymodbus sim profiles — PRs 30, 56-60).
- Live-stack smoke tests (`Driver.Galaxy.E2E/LiveStack/`) — optional, gated on presence
of the `OtOpcUaGalaxyHost` service + Galaxy repository on the dev box (PRs 33, 36, 37).
## Rollback
If Phase 2 PR 3 surfaces downstream consumer regressions, `git revert` the deletion commit
restores the four projects intact. The v2 stack continues to ship from the v2 branch.
`git revert` of the deletion commits restores the projects intact. The v2 stack continues
to ship from the `v2` branch regardless.

451
docs/v2/mitsubishi.md Normal file
View File

@@ -0,0 +1,451 @@
# Mitsubishi Electric MELSEC — Modbus TCP quirks
Mitsubishi's MELSEC family speaks Modbus TCP through a patchwork of add-on modules
and built-in Ethernet ports, not a single unified stack. The module names are
confusingly similar (`QJ71MB91` is *serial* RTU, `QJ71MT91` is the TCP/IP module
[9]; `LJ71MT91` is the L-series equivalent; `RJ71EN71` is the iQ-R Ethernet module
with a MODBUS/TCP *slave* mode bolted on [8]; `FX3U-ENET`, `FX3U-ENET-P502`,
`FX3U-ENET-ADP`, `FX3GE` built-in, and `FX5U` built-in are all different code
paths) — and every one of the categories below has at least one trap a textbook
Modbus client gets wrong: hex-numbered X/Y devices colliding with decimal Modbus
addresses, a user-defined "device assignment" parameter block that means *no two
sites are identical*, CDAB-vs-ABCD word order driven by how the ladder built the
32-bit value, sub-spec FC16 caps on the older QJ71MT91, and an FX3U port-502
licensing split that makes `FX3U-ENET` and `FX3U-ENET-P502` different SKUs.
This document catalogues each quirk, cites primary sources, and names the
ModbusPal integration test we'd write for it (convention from
`docs/v2/modbus-test-plan.md`: `Mitsubishi_<model>_<behavior>`).
## Models and server/client capability
| Model | Family | Modbus TCP server | Modbus TCP client | Source |
|------------------------|----------|-------------------|-------------------|--------|
| `QJ71MT91` | MELSEC-Q | Yes (slave) | Yes (master) | [9] |
| `QJ71MB91` | MELSEC-Q | **Serial only** — RS-232/422/485 RTU, *not TCP* | — | [1][3] |
| `LJ71MT91` | MELSEC-L | Yes (slave) | Yes (master) | [10] |
| `RJ71EN71` / `RnENCPU` | MELSEC iQ-R | Yes (slave) | Yes (master) | [8] |
| `RJ71C24` / `RJ71C24-R2` | MELSEC iQ-R | RTU (serial) | RTU (serial) | [13] |
| iQ-R built-in Ethernet | CPU | Yes (slave) | Yes (master) | [7] |
| iQ-F `FX5U` built-in Ethernet | CPU | Yes, firmware ≥ 1.060 [11] | Yes | [7][11][12] |
| `FX3U-ENET` | FX3U bolt-on | Yes (slave), but **not on port 502** [5] | Yes | [4][5] |
| `FX3U-ENET-P502` | FX3U bolt-on | Yes (slave), port 502 enabled | Yes | [5] |
| `FX3U-ENET-ADP` | FX3U adapter | **No MODBUS** [5] | No MODBUS | [5] |
| `FX3GE` built-in | FX3GE CPU | No MODBUS (needs ENET module) [6] | No | [6] |
| `FX3G` + `FX3U-ENET` | FX3G | Yes via ENET module | Yes | [6] |
- A common integration mistake is to buy `FX3U-ENET-ADP` expecting MODBUS —
that adapter speaks only MC protocol / SLMP. Our driver should surface a clear
capability error, not "connection refused", when the operator's device tag
says `FX3U-ENET-ADP` [5].
- Older forum threads assert the FX5U is "client only" [12] — that was true on
firmware ≤ 1.040. Firmware 1.060 and later ship the parameter-driven MODBUS
TCP server built-in and need no function blocks [11].
## Modbus device assignment (the parameter block)
Unlike a DL260 where the CPU exposes a *fixed* V-memory-to-Modbus mapping, every
MELSEC MODBUS-TCP module exposes a **Modbus Device Assignment Parameter** block
that the engineer configures in GX Works2 / GX Configurator-MB / GX Works3.
Each of the four Modbus tables (Coil, Input, Input Register, Holding Register)
can be split into up to 16 independent "assignment" entries, each binding a
contiguous Modbus address range to a MELSEC device head (`M0`, `D0`, `X0`,
`Y0`, `B0`, `W0`, `SM0`, `SD0`, `R0`, etc.) and a point count [3][7][8][9].
- **There is no canonical "MELSEC Modbus mapping"**. Two sites running the same
QJ71MT91 module can expose completely different Modbus layouts. Our driver
must treat the mapping as site-data (config-file-driven), not as a device
profile constant.
- **Default values do exist** — both GX Configurator-MB (for Q/L series) and
GX Works3 (for iQ-R / iQ-F / FX5) ship a "dedicated pattern" default that is
applied when the engineer does not override the assignment. Per the FX5
MODBUS Communication manual (JY997D56101) and the QJ71MT91 manual, the FX5
dedicated default is [3][7][11]:
| Modbus table | Modbus range (0-based) | MELSEC device | Head |
|--------------------|------------------------|---------------|------|
| Coil (FC01/05/15) | 0 7679 | M | M0 |
| Coil | 8192 8959 | Y | Y0 |
| Input (FC02) | 0 7679 | M | M0 |
| Input | 8192 8959 | X | X0 |
| Input Register (FC04) | 0 6143 | D | D0 |
| Holding Register (FC03/06/16) | 0 6143 | D | D0 |
This matches the widely circulated "FC03 @ 0 = D0" convention that shows up
in Ubidots / Ignition / AdvancedHMI integration guides [6][12].
- **X/Y in the default mapping occupy a second, non-zero Modbus range** (8192+
on FX5; similar on Q/L/iQ-R). Driver users who expect "X0 = coil 0" will be
reading M0 instead. Document this clearly.
- **Assignment-range collisions silently disable the slave.** The QJ71MT91
manual states explicitly that if any two of assignments 1-16 duplicate the
head Modbus device number, the slave function is inactive with no clear
error — the module just won't respond [9]. The driver probe will look like a
simple timeout; the site engineer has to open GX Configurator-MB to diagnose.
Test names:
`Mitsubishi_FX5U_default_mapping_coil_0_is_M0`,
`Mitsubishi_FX5U_default_mapping_holding_0_is_D0`,
`Mitsubishi_QJ71MT91_duplicate_assignment_head_disables_slave`.
## X/Y addressing — hex on MELSEC, decimal on Modbus
**MELSEC X (input) and Y (output) device numbers are hexadecimal on Q / L /
iQ-R** and **octal** on FX / iQ-F (with a GX Works3 toggle) [14][15].
- On a Q CPU, `X20` means decimal **32**, not 20. On an FX5U in default (octal)
mode, `X20` means decimal **16**. GX Works3 exposes a project-level option to
display FX5U X/Y in hex to match Q/L/iQ-R convention — the same physical
input is then called `X10` [14].
- The Modbus Device Assignment Parameter block takes the *head device* as a
MELSEC-native number, which is interpreted in the CPU's native base
(hex for Q/L/iQ-R, octal for FX/iQ-F). After that, **Modbus offsets from
the head are plain decimal** — the module does not apply a second hex
conversion [3][9].
- Example (QJ71MT91 on a Q CPU): assignment "Coil 0 = X0, 512 points" exposes
physical `X0` through `X1FF` (hex) as coils 0-511. A client reading coil 32
gets the bit `X20` (hex) — i.e. the 33rd input, not the value at "input 20"
that the operator wrote on the wiring diagram in decimal.
- **Driver bug source**: if the operator's tag configuration says "read X20" and
the driver helpfully converts "20" to decimal 20 → coil offset 20, the
returned bit is actually `X14` (hex) — off by twelve. Our config layer must
preserve the MELSEC-native base that the site engineer sees in GX Works.
- Timers/counters (`T`, `C`, `ST`) are always decimal in MELSEC notation.
Internal relays (`M`, `B`, `L`), data registers (`D`, `W`, `R`, `ZR`),
and special relays/registers (`SM`, `SD`) also decimal. **Only `X` and `Y`
(and on Q/L/iQ-R, `B` link relays and `W` link registers) use hex**, and
the X/Y decision is itself family-dependent [14][15].
Test names:
`Mitsubishi_Q_X_address_is_hex_X20_equals_coil_offset_32`,
`Mitsubishi_FX5U_X_address_is_octal_X20_equals_coil_offset_16`,
`Mitsubishi_W_link_register_is_hex_W10_equals_holding_offset_16`.
## Word order for 32-bit values
MELSEC stores 32-bit ladder values (`DINT`, `DWORD`, `REAL` / single-precision
float) across **two consecutive D-registers, low word first** — i.e., `CDAB`
when viewed as a Modbus register pair [2][6].
```
D100 (low word) : 0xCC 0xDD (big-endian bytes within the word)
D101 (high word) : 0xAA 0xBB
```
A Modbus master reading D100/D101 as a `float` with default (ABCD) word order
gets garbage. Ignition's built-in Modbus driver notes Mitsubishi as a "CDAB
device" specifically for this reason [2].
- **Q / L / iQ-R / iQ-F all agree** — this is a CPU-level convention, not a
module choice. Both the QJ71MT91 manual and the FX5 MODBUS Communication
manual describe 32-bit access by "reading the lower 16 bits from the start
address and the upper 16 bits from start+1" [6][11].
- **Byte order within each register is big-endian** (Modbus standard). The
module does not byte-swap.
- **Configurable?** The MODBUS modules themselves do **not** expose a word-
order toggle; the behavior is fixed to how the CPU laid out the value in the
two D-registers. If the ladder programmer used an `SWAP` instruction or a
union-style assignment, the word order can be whatever they made it — but
for values produced by the standard `D→DBL` and `FLT`/`FLT2` instructions
it is always CDAB [2].
- **FX5U quirk**: the FX5 MODBUS Communication manual tells the programmer to
use the `SWAP` instruction *if* the remote Modbus peer requires
little-endian *byte* ordering (BADC) [11]. This is only relevant when the
FX5U is the Modbus *client*, but it confirms the FX5U's native wire layout
is big-endian-byte / little-endian-word (CDAB) on the server side too.
- **Rumoured exception**: a handful of MrPLC forum threads report iQ-R
RJ71EN71 firmware < 1.05 returning DWORDs in `ABCD` order when accessed via
the built-in Ethernet port's MODBUS slave [8]. _Unconfirmed_; treat as a
per-site test.
Test names:
`Mitsubishi_Float32_word_order_is_CDAB`,
`Mitsubishi_Int32_word_order_is_CDAB`,
`Mitsubishi_FX5U_SWAP_instruction_changes_byte_order_not_word_order`.
## BCD vs binary encoding
**MELSEC stores integer values in D-registers as plain binary two's-complement**,
not BCD [16]. This is the opposite of AutomationDirect DirectLOGIC, where
V-memory defaults to BCD and the ladder must explicitly request binary.
- A ladder `MOV K1234 D100` stores `0x04D2` (1234 decimal) in D100, not
`0x1234`. The Modbus master reads `0x04D2` and decodes it as an integer
directly — no BCD conversion needed [16].
- **Timer / counter current values** (`T0` current value, `C0` count) are
stored in binary as word devices on Q/L/iQ-R/iQ-F. The ladder preset
(`K...`) is also binary [16][17].
- **Timer / counter preset `K` operand in FX3U / earlier FX**: also binary when
loaded from a D-register or a `K` constant. The older A-series CPUs had BCD
presets on some timer types, but MELSEC-Q, L, iQ-R, iQ-F, and FX3U all use
binary presets by default [17].
- The FX3U programming manual dedicates `FNC 18 BCD` and `FNC 19 BIN` to
explicit conversion — their existence confirms that anything in D-registers
that came from a `BCD` instruction output is BCD, but nothing is BCD by
default [17].
- **7-segment display registers** are a common site-specific exception — many
ladders pack `BCD D100` into a D-register so the operator panel can drive
a display directly. Our driver should not assume; expose a per-tag
"encoding = binary | BCD" knob.
Test names:
`Mitsubishi_D_register_stores_binary_not_BCD`,
`Mitsubishi_FX3U_timer_current_value_is_binary`.
## Max registers per request
From the FX5 MODBUS Communication manual Chapter 11 [11]:
| FC | Name | FX5U (built-in) | QJ71MT91 | iQ-R (RJ71EN71 / built-in) | FX3U-ENET |
|----|----------------------------|-----------------|--------------|-----------------------------|-----------|
| 01 | Read Coils | 1-2000 | 1-2000 [9] | 1-2000 [8] | 1-2000 |
| 02 | Read Discrete Inputs | 1-2000 | 1-2000 | 1-2000 | 1-2000 |
| 03 | Read Holding Registers | **1-125** | 1-125 [9] | 1-125 [8] | 1-125 |
| 04 | Read Input Registers | 1-125 | 1-125 | 1-125 | 1-125 |
| 05 | Write Single Coil | 1 | 1 | 1 | 1 |
| 06 | Write Single Register | 1 | 1 | 1 | 1 |
| 0F | Write Multiple Coils | 1-1968 | 1-1968 | 1-1968 | 1-1968 |
| 10 | Write Multiple Registers | **1-123** | 1-123 | 1-123 | 1-123 |
| 16 | Mask Write Register | 1 | not supported | 1 | not supported |
| 17 | Read/Write Multiple Regs | R:1-125, W:1-121 | not supported | R:1-125, W:1-121 | not supported |
- **The FX5U / iQ-R native-port limits match the Modbus spec**: 125 for FC03/04,
123 for FC16 [11]. No sub-spec caps like DL260's 100-register ceiling.
- **QJ71MT91 does not support FC16 (0x16, Mask Write Register) or FC17
(0x17, Read/Write Multiple)** — requesting them returns exception `01`
Illegal Function [9]. FX5U and iQ-R *do* support both.
- **QJ71MT91 device size**: 64k points (65,536) for each of Coil / Input /
Input Register / Holding Register, plus up to 4086k points for Extended
File Register via a secondary assignment range [9].
- **FX3U-ENET / -P502 function code list is a strict subset** of the common
eight (FC01/02/03/04/05/06/0F/10). FC16 and FC17 not supported [4].
Test names:
`Mitsubishi_FX5U_FC03_126_registers_returns_IllegalDataValue`,
`Mitsubishi_FX5U_FC16_124_registers_returns_IllegalDataValue`,
`Mitsubishi_QJ71MT91_FC16_MaskWrite_returns_IllegalFunction`,
`Mitsubishi_QJ71MT91_FC23_ReadWrite_returns_IllegalFunction`.
## Exception codes
MELSEC MODBUS modules return **only the standard Modbus exception codes 01-04**;
no proprietary exception codes are exposed on the wire [8][9][11]. Module-
internal diagnostics (buffer-memory error codes like `7380H`) are logged but
not returned as Modbus exceptions.
| Code | Name | MELSEC trigger |
|------|----------------------|---------------------------------------------------------|
| 01 | Illegal Function | FC17 or FC16 on QJ71MT91/FX3U; FC08 (Diagnostics); FC43 |
| 02 | Illegal Data Address | Modbus address outside any assignment range |
| 03 | Illegal Data Value | Quantity out of per-FC range (see table above); odd coil-byte count |
| 04 | Server Device Failure | See below |
- **04 (Server Failure) triggers on MELSEC**:
- CPU in STOP or PAUSE during a write to an assignment whose "Access from
External Device" permission is set to "Disabled in STOP" [9][11].
*With the default "always enabled" setting the write succeeds in STOP
mode* — another common trap.
- CPU errors (parameter error, watchdog) during any access.
- Assignment points to a device range that is not configured (e.g. write
to `D16384` when CPU D-device size is 12288).
- **Write to a "System Area" device** (e.g., `SD` special registers that are
CPU-reserved read-only) returns `04`, not `02`, on QJ71MT91 and iQ-R — the
assignment is valid, the device exists, but the CPU rejects the write [8][9].
- **FX3U-ENET / -P502** returns `04` on any write attempt while the CPU is in
STOP, regardless of permission settings — the older firmware does not
implement the "Access from External Device" granularity that Q/L/iQ-R/iQ-F
expose [4].
- **No rumour of proprietary codes 05-0B** from MELSEC; operators sometimes
report "exception 0A" but those traces all came from a third-party gateway
sitting between the master and the MELSEC module.
Test names:
`Mitsubishi_QJ71MT91_STOP_mode_write_with_Disabled_permission_returns_ServerFailure`,
`Mitsubishi_QJ71MT91_STOP_mode_write_with_default_permission_succeeds`,
`Mitsubishi_SD_system_register_write_returns_ServerFailure`,
`Mitsubishi_FX3U_STOP_mode_write_always_returns_ServerFailure`.
## Connection behavior
Max simultaneous Modbus TCP clients, per module [7][8][9][11]:
| Model | Max TCP connections | Port 502 | Keepalive | Source |
|----------------------|---------------------|----------|-----------|--------|
| `QJ71MT91` | 16 (shared with master role) | Yes | No | [9] |
| `LJ71MT91` | 16 | Yes | No | [10] |
| iQ-R built-in / `RJ71EN71` | 16 | Yes | Configurable (KeepAlive = ON in parameter) | [8] |
| iQ-F `FX5U` built-in | 8 | Yes | Configurable | [7][11] |
| `FX3U-ENET` | 8 TCP, but **not port 502** | No (port < 1024 blocked) | No | [4][5] |
| `FX3U-ENET-P502` | 8, port 502 enabled | Yes | No | [5] |
- **QJ71MT91's 16 is total connections shared between slave-listen and
master-initiated sockets** [9]. A site that uses the same module as both
master to downstream VFDs and slave to upstream SCADA splits the 16 pool.
- **FX3U-ENET port-502 gotcha**: if the engineer loads a configuration with
port 502 into a non-P502 ENET module, GX Works shows the download as
successful; on next power cycle the module enters error state and the
MODBUS listener never starts. This is documented on third-party FX3G
integration guides [6].
- **CPU STOP → RUN transition**: does **not** drop Modbus connections on any
MELSEC family. Existing sockets stay open; outstanding requests during the
transition may see exception 04 for a few scans but then resume [8][9].
- **CPU reset (power cycle or `SM1255` forced reset)** drops all Modbus
connections and the module re-listens after typically 5-10 seconds.
- **Idle timeout**: QJ71MT91 and iQ-R have a per-connection "Alive-Check"
(idle timer) parameter, default 0 (disabled). If enabled, default 10 s
probe interval, 3 retries before close [8][9]. FX5U similar defaults.
- **Keep-alive (TCP-level)**: only iQ-R / iQ-F expose a TCP keep-alive option
(parameter "KeepAlive" in the Ethernet settings); QJ71MT91 and FX3U-ENET
do not — so NAT/firewall idle drops require driver-side pinging.
Test names:
`Mitsubishi_QJ71MT91_17th_connection_refused`,
`Mitsubishi_FX5U_9th_connection_refused`,
`Mitsubishi_STOP_to_RUN_transition_preserves_socket`,
`Mitsubishi_CPU_reset_closes_all_sockets`.
## Behavioral oddities
- **Transaction ID echo**: QJ71MT91 and iQ-R reliably echo the MBAP TxId on
every response across firmware revisions; no reports of TxId drops under
load [8][9]. FX3U-ENET has an older, less-tested TCP stack; at least one
MrPLC thread reports out-of-order TxId echoes under heavy polling on
firmware < 1.14 [4]. _Unconfirmed_ on current firmware.
- **Per-connection request serialization**: all MELSEC slaves serialize
requests within a single TCP connection — a new request is not processed
until the prior response has been sent. Pipelining multiple requests on one
socket causes the module to queue them in buffer memory and respond in
order, but **the queue depth is 1** on QJ71MT91 (a second in-flight request
is held on the TCP receive buffer, not queued) [9]. Driver should treat
Mitsubishi slaves as strictly single-flight per socket.
- **Partial-frame handling**: QJ71MT91 and iQ-R close the socket on malformed
MBAP length fields. FX5U resynchronises at the next valid MBAP header
within 100 ms but will emit an error to `SD` diagnostics [11]. Driver must
reconnect on half-close and replay.
- **FX3U UDP vs TCP**: `FX3U-ENET` supports both UDP and TCP MODBUS transports;
UDP is lossy and reorders under load. Default is TCP. Some legacy SCADA
configurations pinned the module to UDP for multicast discovery — do not
select UDP unless the site requires it [4].
- **Known firmware-revision variants**:
- QJ71MT91 ≤ firmware 10052000000 (year-month format): FC15 with coil
count that forces byte-count to an odd value silently truncates the
last coil. Fixed in later revisions [9]. _Operator-reported_.
- FX5U firmware < 1.060: no native MODBUS TCP server — only accessible via
a predefined-protocol function block hack. Firmware ≥ 1.060 ships
parameter-based server. Our capability probe should read `SD203`
(firmware version) and flag < 1.060 as unsupported for server mode [11][12].
- iQ-R RJ71EN71 early firmware: possible ABCD word order (rumoured,
unconfirmed) [8].
- **SD (special-register) reads during assignment-parameter load**: while
the CPU is loading a new MODBUS device assignment parameter (~1-2 s), the
slave returns exception 04 Server Failure on every request. Happens after
a parameter write from GX Configurator-MB [9].
- **iQ-R "Station-based block transfer" collision**: if the RJ71EN71 is also
running CC-Link IE Control on the same module, a MODBUS/TCP request that
arrives during a CCIE cyclic period is delayed to the next scan — visible
as jittery response time, not a failure [8].
Test names:
`Mitsubishi_QJ71MT91_single_flight_per_socket`,
`Mitsubishi_FX5U_malformed_MBAP_resync_within_100ms`,
`Mitsubishi_FX3U_TxId_preserved_across_burst`,
`Mitsubishi_FX5U_firmware_below_1_060_reports_no_server_mode`.
## Model-specific differences for test coverage
Summary of which quirks differ per model, so test-class naming can reflect them:
| Quirk | QJ71MT91 | LJ71MT91 | iQ-R (RJ71EN71 / built-in) | iQ-F (FX5U) | FX3U-ENET(-P502) |
|------------------------------------------|----------|----------|----------------------------|-------------|------------------|
| FC16 Mask-Write supported | No | No | Yes | Yes | No |
| FC17 Read/Write Multiple supported | No | No | Yes | Yes | No |
| Max connections | 16 | 16 | 16 | 8 | 8 |
| X/Y numbering base | hex | hex | hex | octal (default) | octal |
| 32-bit word order | CDAB | CDAB | CDAB (firmware-dependent rumour of ABCD) | CDAB | CDAB |
| Port 502 supported | Yes | Yes | Yes | Yes | P502 only |
| STOP-mode write permission configurable | Yes | Yes | Yes | Yes | No (always blocks) |
| TCP keep-alive parameter | No | No | Yes | Yes | No |
| Modbus device assignment — max entries | 16 | 16 | 16 | 16 | 8 |
| Server via parameter (no FB) | Yes | Yes | Yes | Yes (fw ≥ 1.060) | Yes |
- **Test file layout**: `Mitsubishi_QJ71MT91_*`, `Mitsubishi_LJ71MT91_*`,
`Mitsubishi_iQR_*`, `Mitsubishi_FX5U_*`, `Mitsubishi_FX3U_ENET_*`,
`Mitsubishi_FX3U_ENET_P502_*`. iQ-R built-in Ethernet and the RJ71EN71
behave identically for MODBUS/TCP slave purposes and can share a file
`Mitsubishi_iQR_*`.
- **Cross-model shared tests** (word order CDAB, binary not BCD, standard
exception codes, 125-register FC03 cap) can live in a single
`Mitsubishi_Common_*` fixture.
## References
1. Mitsubishi Electric, *MODBUS Interface Module User's Manual — QJ71MB91*
(SH-080578ENG), RS-232/422/485 MODBUS RTU serial module for MELSEC-Q —
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/sh080578eng/sh080578engk.pdf
2. Inductive Automation, *Ignition Modbus Driver — Mitsubishi Q / iQ-R word
order*, documents CDAB convention —
https://docs.inductiveautomation.com/docs/8.1/ignition-modules/opc-ua/drivers/modbus-v2
and forum discussion https://forum.inductiveautomation.com/t/modbus-tcp-device-word-byte-order/65984
3. Mitsubishi Electric, *Programmable Controller User's Manual QJ71MB91 MODBUS
Interface Module*, Chapter 7 "Parameter Setting" describing the Modbus
Device Assignment Parameter block (assignments 1-16, head-device
configuration) —
https://www.lcautomation.com/dbdocument/29156/QJ71MB91%20Users%20manual.pdf
4. Mitsubishi Electric, *FX3U-ENET User's Manual* (JY997D18101), Chapter on
MODBUS/TCP communication; function code support and connection limits —
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc_fx/jy997d18101/jy997d18101h.pdf
5. Venus Automation, *Mitsubishi FX3U-ENET-P502 Module — Open Port 502 for
Modbus TCP/IP* —
https://venusautomation.com.au/mitsubishi-fx3u-enet-p502-module-open-port-502-for-modbus-tcp-ip/
and FX3U-ENET-ADP user manual (JY997D45801), which confirms the -ADP
variant does not support MODBUS —
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc_fx/jy997d45801/jy997d45801h.pdf
6. XML Control / Ubidots integration notes, *FX3G Modbus* — port-502 trap,
D-register mapping default, word order reference —
https://sites.google.com/site/xmlcontrol/archive/fx3g-modbus
and https://ubidots.com/blog/mitsubishi-plc-as-modbus-tcp-server/
7. FA Support Me, *Modbus TCP on Built-in Ethernet port in iQ-F and iQ-R*
confirms 16-connection limit on iQ-R, 8 on iQ-F, parameter-driven
configuration via GX Works3 —
https://www.fasupportme.com/portal/en/kb/articles/modbus-tcp-on-build-in-ethernet-port-in-iq-f-and-iq-r-en
8. Mitsubishi Electric, *MELSEC iQ-R Ethernet User's Manual (Application)*
(SH-081259ENG) and *MELSEC iQ-RJ71EN71 User's Manual* Chapter on
"Communications Using Modbus/TCP" —
https://www.allied-automation.com/wp-content/uploads/2015/02/MITSUBISHI_manual_plc_iq-r_ethernet_users.pdf
and https://www.manualslib.com/manual/1533351/Mitsubishi-Electric-Melsec-Iq-Rj71en71.html?page=109
9. Mitsubishi Electric, *MODBUS/TCP Interface Module User's Manual — QJ71MT91*
(SH-080446ENG), exception codes page 248, device assignment parameter
pages 116-124, duplicate-assignment-disables-slave note —
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/sh080446eng/sh080446engj.pdf
10. Mitsubishi Electric, *MELSEC-L Network Features* — LJ71MT91 documented as
L-series equivalent of QJ71MT91 with identical MODBUS/TCP behavior —
https://us.mitsubishielectric.com/fa/en/products/cnt/programmable-controllers/melsec-l-series/network/features/
11. Mitsubishi Electric, *MELSEC iQ-F FX5 User's Manual (MODBUS Communication)*
(JY997D56101), Chapter 11 "Modbus/TCP Communication Specifications" —
function code max-quantity table, frame specification, device assignment
defaults —
https://dl.mitsubishielectric.com/dl/fa/document/manual/plcf/jy997d56101/jy997d56101h.pdf
12. MrPLC forum, *FX5U Modbus-TCP Server (Slave)*, firmware ≥ 1.60 enables
native server via parameter; earlier firmware required function block —
https://mrplc.com/forums/topic/31883-fx5u-modbus-tcp-server-slave/
and Industrial Monitor Direct's "FX5U MODBUS TCP Server Workaround"
article (reflects older firmware behavior) —
https://industrialmonitordirect.com/blogs/knowledgebase/mitsubishi-fx5u-modbus-tcp-server-configuration-workaround
13. Mitsubishi Electric, *MELSEC iQ-R MODBUS and MODBUS/TCP Reference Manual —
RJ71C24 / RJ71C24-R2* (BCN-P5999-1060) — RJ71C24 is serial RTU only,
not TCP —
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc/bcn-p5999-1060/bcnp59991060b.pdf
14. HMS Industrial Networks, *eWON and Mitsubishi FX5U PLC* (KB-0264-00) —
documents that FX5U X/Y are octal in GX Works3 but hex when viewed as a
Q-series PLC through eWON; the project-level hex/octal toggle —
https://hmsnetworks.blob.core.windows.net/www/docs/librariesprovider10/downloads-monitored/manuals/knowledge-base/kb-0264-00-en-ewon-and-mitsubishi-fx5u-plc.pdf
15. Fernhill Software, *Mitsubishi Melsec PLC Data Address* — documents
hex-vs-octal device numbering split across MELSEC families —
https://www.fernhillsoftware.com/help/drivers/mitsubishi-melsec/data-address-format.html
16. Inductive Automation support, *Understanding Mitsubishi PLCs* — D registers
store signed 16-bit binary, not BCD; DINT combines two consecutive D
registers —
https://support.inductiveautomation.com/hc/en-us/articles/16517576753165-Understanding-Mitsubishi-PLCs
17. Mitsubishi Electric, *FXCPU Structured Programming Manual [Device &
Common]* (JY997D26001) — FNC 18 BCD and FNC 19 BIN explicit-conversion
instructions confirm binary-by-default storage —
https://dl.mitsubishielectric.com/dl/fa/document/manual/plc_fx/jy997d26001/jy997d26001l.pdf

485
docs/v2/s7.md Normal file
View File

@@ -0,0 +1,485 @@
# Siemens SIMATIC S7 (S7-1200 / S7-1500 / S7-300 / S7-400 / ET 200SP) — Modbus TCP quirks
Siemens S7 PLCs do *not* speak Modbus TCP natively at the OS/firmware level. Every
S7 Modbus-TCP-server deployment is either (a) the **`MB_SERVER`** library block
running on the CPU's PROFINET port (S7-1200 / S7-1500 / CPU 1510SP-series
ET 200SP), or (b) the **`MODBUSCP`** function block running on a separate
communication processor (**CP 343-1 / CP 343-1 Lean** on S7-300, **CP 443-1** on
S7-400), or (c) the **`MODBUSPN`** block on an S7-1500 PN port via a licensed
library. That means the quirks a Modbus client has to cope with are as much
"this is how the user's PLC programmer wired the library block up" as "this is
how the firmware behaves" — the byte-order and coil-mapping rules aren't
hard-wired into silicon like they are on a DL260. This document catalogues the
behaviours a driver has to handle across the supported model/CP variants, cites
primary sources, and names the ModbusPal integration test we'd write for each
(convention from `docs/v2/modbus-test-plan.md`: `S7_<model>_<behavior>`).
## Model / CP Capability Matrix
| PLC family | Modbus TCP server mechanism | Modbus TCP client mechanism | License required? | Typical port 502 source |
|---------------------|------------------------------------|------------------------------------|-----------------------|-----------------------------------------------------------|
| S7-1200 (V4.0+) | `MB_SERVER` on integrated PN port | `MB_CLIENT` | No (in TIA Portal) | CPU's onboard Ethernet [1][2] |
| S7-1500 (all) | `MB_SERVER` on integrated PN port | `MB_CLIENT` | No (in TIA Portal) | CPU's onboard Ethernet [1][3] |
| S7-1500 + CP 1543-1 | `MB_SERVER` on CP's IP | `MB_CLIENT` | No | Separate CP IP address [1] |
| ET 200SP CPU (1510SP, 1512SP) | `MB_SERVER` on PN port | `MB_CLIENT` | No | CPU's onboard Ethernet [3] |
| S7-300 + CP 343-1 / CP 343-1 Lean | `MODBUSCP` (FB `MODBUSCP`, instance DB per connection) | Same FB, client mode | **Yes — 2XV9450-1MB00** per CP | CP's Ethernet port [4][5] |
| S7-400 + CP 443-1 | `MODBUSCP` | `MODBUSCP` client mode | **Yes — 2XV9450-1MB00** per CP | CP's Ethernet port [4] |
| S7-400H + CP 443-1 (redundant H) | `MODBUSCP_REDUNDANT` / paired FBs | Not typical | Yes | Paired CPs in H-system [6] |
| S7-300 / S7-400 CPU PN (e.g. CPU 315-2 PN/DP) | `MODBUSPN` library | `MODBUSPN` client mode | **Yes** — Modbus-TCP PN CPU lib | CPU's PN port [7] |
| "CP 343-1 Lean" | **Server only** (no client mode supported by Lean) | — | Yes, but with restrictions | CP's Ethernet port [4][5] |
- **CP 343-1 Lean is server-only.** It can host `MODBUSCP` in server mode only;
client calls return an immediate error. A surprising number of "Lean + client
doesn't work" forum posts trace back to this [5].
- **Pure OPC UA / PROFINET CPs (CP 1542SP-1, CP 1543-1)** support Modbus TCP on
S7-1500 via the same `MB_SERVER`/`MB_CLIENT` instructions by passing the
CP's `hw_identifier`. There is no separate "Modbus CP" license needed on
S7-1500, unlike S7-300/400 [1].
- **No S7 Modbus server supports function codes 20/21 (file records),
22 (mask write), 23 (read-write multiple), or 43 (device identification).**
Sending any of these returns exception `01` (Illegal Function) on every S7
variant [1][4]. Our driver must not negotiate FC23 as a "bulk-read optimization"
when the profile is S7.
Test names:
`S7_1200_MBSERVER_Loads_OB1_Cyclic`,
`S7_CP343_Lean_Client_Mode_Rejected`,
`S7_All_FC23_Returns_IllegalFunction`.
## Address / DB Mapping
S7 Modbus servers **do not auto-expose PLC memory** — the PLC programmer has to
wire one area per Modbus table to a DB or process-image region. This is the
single biggest difference vs. DL205/Modicon/etc., where the memory map is
fixed at the factory. Our driver must therefore be tolerant of "the same
`40001` means completely different things on two S7-1200s on the same site."
### S7-1200 / S7-1500 `MB_SERVER`
The `MB_SERVER` instance exposes four Modbus tables to each connected client;
each table's backing storage is a per-block parameter [1][8]:
| Modbus table | FCs | Backing parameter | Default / typical backing |
|---------------------|-------------|-----------------------------|-----------------------------|
| Coils (0x) | FC01, FC05, FC15 | *implicit* — Q process image | `%Q0.0``%Q1023.7` (→ coil addresses 08191) [1][9] |
| Discrete Inputs (1x)| FC02 | *implicit* — I process image | `%I0.0``%I1023.7` (→ discrete addresses 08191) [1][9] |
| Input Registers (3x)| FC04 | *implicit* — M memory or DB (version-dependent) | Some firmware routes FC04 through the same MB_HOLD_REG buffer [1][8] |
| Holding Registers (4x)| FC03, FC06, FC16 | `MB_HOLD_REG` pointer | User DB (e.g. `DB10.DBW0`) or `%MW` area [1][2][8] |
- **`MB_HOLD_REG` is a pointer (VARIANT / ANY) into a user-defined DB** whose
first byte is holding-register 0 (`40001` in 1-based Modicon form). Byte
offset 2 is register 1, byte offset 4 is register 2, etc. [1][2].
- **The DB *must* have "Optimized block access" UNCHECKED.** Optimized DBs let
the compiler reorder fields for alignment; Modbus requires fixed byte
offsets. With optimized access on, the compiler accepts the project but
`MB_SERVER` returns STATUS `0x8383` (misaligned access) or silently reads
zeros [8][10][11]. This is the #1 support-forum complaint.
- **FC01/FC02/FC05/FC15 hit the Q and I process images directly — not the
`MB_HOLD_REG` DB.** Coil address 0 = `%Q0.0`, coil 1 = `%Q0.1`, coil 8 =
`%Q1.0`. The S7-1200 system manual publishes this mapping as `00001 → Q0.0`
through `09999 → Q1023.7` and `10001 → I0.0` through `19999 → I1023.7` in
1-based form; on the wire (0-based) that's coils 0-8191 and discrete inputs
0-8191 [9].
- **`%M` markers are NOT automatically exposed.** To expose `%M` over Modbus
the programmer must either (a) copy `%M` to the `MB_HOLD_REG` DB each scan,
or (b) define an Array\[0..n\] of Bool inside that DB and copy bits in/out
of `%M`. Siemens has no "MB_COIL_REG" parameter analogous to
`MB_HOLD_REG` — this confuses users migrating from Schneider [9][12].
- **Bit ordering within a Modbus holding register sourced from an `Array of
Bool`**: S7 stores bool\[0\] at `DBX0.0` which is bit 0 of byte 0 which is
the **low byte, low bit** of Modbus register `40001`. A naive client that
reads register `40001` and masks `0x0001` gets bool\[0\]. A client that
masks `0x8000` gets bool\[15\] because the high byte of the Modbus register
is the *second* byte of the DB. Siemens programmers routinely get this
wrong in the DB-via-DBX form; `Array[0..n] of Bool` is the recommended
layout because it aligns naturally [12][13].
### S7-300/400 + CP 343-1 / CP 443-1 `MODBUSCP`
Different paradigm: per-connection **parameter DB** (template
`MODBUS_PARAM_CP`) declares a table of up to 8 register-area mappings. Each
mapping is a tuple `(data_type, DB#, start_offset, length)` where `data_type`
picks the Modbus table [4]:
- `B#16#1` = Coils
- `B#16#2` = Discrete Inputs
- `B#16#3` = Holding Registers
- `B#16#4` = Input Registers
The `holding_register_start` and analogous `coils_start` parameters declare
**which Modbus address range** the CP will serve, and the DB pointers say
where in S7 memory that range lives [4][14]. Unlike `MB_SERVER`, the CP does
not reach into `%Q`/`%I` directly — *everything* goes through a DB. If an
address outside the declared ranges is requested, the CP returns exception
`02` (Illegal Data Address) [4].
Test names:
`S7_1200_FC03_Reg0_Reads_DB10_DBW0`,
`S7_1200_Optimized_DB_Returns_0x8383_MisalignedAccess`,
`S7_1200_FC01_Coil0_Reads_Q0_0`,
`S7_CP343_FC03_Outside_ParamBlock_Range_Returns_IllegalDataAddress`.
## Data Types and Byte Order
Siemens CPUs store scalars **big-endian** internally ("Motorola format"), which
is the same byte order Modbus specifies inside each register. So for 16-bit
values (`Int`, `Word`, `UInt`) the on-the-wire layout is straightforward
`AB` — high byte of the PLC value in the high byte of the Modbus register
[15][16]. No byte-swap trap for 16-bit types.
The trap is 32-bit types (`DInt`, `DWord`, `Real`). Here's what actually
happens across the S7 family:
### S7-1200 / S7-1500 `MB_SERVER`
- **The backing DB stores 32-bit values in big-endian byte order, high word
first** — i.e. `ABCD` when viewed as two consecutive Modbus registers. A
`Real` at `DB10.DBD0` with value `0x12345678` reads over Modbus as
register 0 = `0x1234`, register 1 = `0x5678` [15][16][17].
- **This is `ABCD`, *not* `CDAB`.** Clients that hard-code CDAB (common default
for meters and VFDs) will get wildly wrong floats. Configure the S7 profile
with `WordOrder = ABCD` (aka "big-endian word + big-endian byte" aka
"high-word first") [15][17].
- **`MB_SERVER` does not swap.** It's a direct memcpy from the DB bytes to
the Modbus payload. Whatever byte order the ladder programmer stored into
the DB is what the client receives [17]. This means a programmer who used
`MOVE_BLK` from two separate `Word`s into `DBD` with the "wrong" order can
produce `CDAB` without realising.
- **`Real` is IEEE 754 single-precision** — unambiguous, no BCD trap like on
DL series [15].
- **Strings**: S7 `String[n]` has a 2-byte header (max length, current length)
*before* the character bytes. A client reading a string over Modbus gets
the header in the first register and then the characters two-per-register
in high-byte-first order. `WString` is UTF-16 and the header is 4 bytes
[18]. Our driver's string decoder must expose the "skip header" option for
S7 profile.
### S7-300/400 `MODBUSCP` (CP 343-1 / CP 443-1)
- The CP writes the exact DB bytes onto the wire — again `ABCD` if the DB
stores `DInt`/`Real` in native Siemens order [4].
- **`MODBUSCP` has no `data_type` byte-swap knob.** (The `data_type` parameter
names the Modbus table, not the byte order — see the Address Mapping
section.) If the other end of the link expects `CDAB`, the programmer has
to swap words in ladder before writing the DB [4][14].
### Operator-reported oddity
- Some S7 drivers (Kepware's "Siemens TCP/IP Ethernet" driver, Ignition's
"Siemens S7" driver) expose a per-tag `Float Byte Order` with options
`ABCD`/`CDAB`/`BADC`/`DCBA` because end-users have encountered every
permutation in the field — not because the PLC natively swaps, but because
ladder programmers have historically stored floats every which way [19].
Our S7 Modbus profile should default to `ABCD` but expose a per-tag
override.
- **Unconfirmed rumour**: that S7-1500 firmware V2.0+ reverses float byte
order for `MB_CLIENT` only. Not reproduced; the Siemens forum thread that
launched it was a user error (the remote server was the swapper, not the
S7) [20]. Treat as false until proven.
Test names:
`S7_1200_Real_WordOrder_ABCD_Default`,
`S7_1200_DInt_HighWord_First_At_DBD0`,
`S7_1200_String_Header_First_Two_Bytes`,
`S7_CP343_No_Internal_ByteSwap`.
## Coil / Discrete Input Mapping
On `MB_SERVER` the mapping from coil address → S7 bit is fixed at the
process-image level [1][9][12]:
| Modbus coil / discrete input addr | S7 address | Notes |
|-----------------------------------|---------------|-------------------------------------|
| Coil 0 (FC01/05/15) | `%Q0.0` | bit 0 of output byte 0 |
| Coil 7 | `%Q0.7` | bit 7 of output byte 0 |
| Coil 8 | `%Q1.0` | bit 0 of output byte 1 |
| Coil 8191 (max) | `%Q1023.7` | highest exposed output bit |
| Discrete input 0 (FC02) | `%I0.0` | bit 0 of input byte 0 |
| Discrete input 8191 | `%I1023.7` | highest exposed input bit |
Formulas:
```
coil_addr = byte_index * 8 + bit_index (e.g. %Q5.3 → coil 43)
discr_addr = byte_index * 8 + bit_index (e.g. %I10.2 → disc 82)
```
- **1-based Modicon form adds 1:** coil 0 (wire) = `00001` (Modicon), etc.
Our driver sends the 0-based PDU form, so `%Q0.0` writes to wire address 0.
- **Writing FC05/FC15 to `%Q` is accepted even while the CPU is in STOP** —
the PLC's process image doesn't care about the user program state. But the
output won't propagate to the physical module until RUN (see STOP section
below) [1][21].
- **`%M` markers require a DB-backed `Array of Bool`** as described in the
Address Mapping section. Our driver can't assume "coil N = MN.0" like it
can on Modicon — on S7 it's always Q/I unless the programmer built a
mapping DB [12].
- **Bit-inside-holding-register**: for `Array of Bool` inside the
`MB_HOLD_REG` DB, bool[0] is bit 0 of byte 0 → **low byte, low bit** of
Modbus register 40001. Most third-party clients probe this in the low
byte, so the common case works; the less-common case (bool[8]) is bit 0 of
byte 1 → **high byte, low bit** of Modbus register 40001. Clients that
test only bool[0] will pass and miss the mis-alignment on bool[8] [12][13].
Test names:
`S7_1200_Coil_0_Is_Q0_0`,
`S7_1200_Coil_8_Is_Q1_0`,
`S7_1200_Discrete_Input_7_Is_I0_7`,
`S7_1200_Coil_Write_In_STOP_Accepted_But_Output_Frozen`.
## Function Code Support & Max Registers Per Request
| FC | Name | S7-1200 / S7-1500 MB_SERVER | CP 343-1 / CP 443-1 MODBUSCP | Max qty per request |
|----|----------------------------|-----------------------------|------------------------------|--------------------------------|
| 01 | Read Coils | Yes | Yes | 2000 bits (spec) |
| 02 | Read Discrete Inputs | Yes | Yes | 2000 bits (spec) |
| 03 | Read Holding Registers | Yes | Yes | **125** (spec max) |
| 04 | Read Input Registers | Yes | Yes | **125** |
| 05 | Write Single Coil | Yes | Yes | 1 |
| 06 | Write Single Register | Yes | Yes | 1 |
| 15 | Write Multiple Coils | Yes | Yes | 1968 bits (spec) — *see note* |
| 16 | Write Multiple Registers | Yes | Yes | **123** (spec max for TCP) |
| 07 | Read Exception Status | No (RTU only) | No | — |
| 17 | Report Server ID | No | No | — |
| 20/21 | Read/Write File Record | No | No | — |
| 22 | Mask Write Register | No | No | — |
| 23 | Read/Write Multiple | No | No | — |
| 43 | Read Device Identification | No | No | — |
- **S7-1200/1500 honour the full spec maxima** for FC03/04 (125) and FC16
(123) [1][22]. No sub-spec cap like DL260's 100-register FC16 limit.
- **FC15 (Write Multiple Coils) on `MB_SERVER`** writes into `%Q`, which maxes
out at 1024 bytes = 8192 bits, but the spec's 1968-bit per-request limit
caps any single call first [1][9].
- **`MB_HOLD_REG` buffer size is bounded by DB size** — max DB size on
S7-1200 is 64 KB, on S7-1500 is much larger (several MB depending on CPU),
so the practical `MB_HOLD_REG` limit is 32767 16-bit registers on S7-1200
and effectively unbounded on S7-1500 [22][23]. The *per-request* limit is
still 125.
- **Read past the end of `MB_HOLD_REG`** returns exception `02` (Illegal
Data Address) at the start of the overflow register, not a partial read
[1][8].
- **Request larger than spec max** (e.g. FC03 quantity 126) returns exception
`03` (Illegal Data Value). Verified on S7-1200 V4.2 [1][24].
- **CP 343-1 `MODBUSCP` per-request maxima are spec** (125/125/123/1968/2000),
matching the standard [4]. The CP's `MODBUS_PARAM_CP` caps the total
*exposed* range, not the per-call quantity.
Test names:
`S7_1200_FC03_126_Registers_Returns_IllegalDataValue`,
`S7_1200_FC16_124_Registers_Returns_IllegalDataValue`,
`S7_1200_FC03_Past_MB_HOLD_REG_End_Returns_IllegalDataAddress`,
`S7_1200_FC17_ReportServerId_Returns_IllegalFunction`.
## Exception Codes
S7 Modbus servers return only the four standard exception codes [1][4]:
| Code | Name | Triggered by |
|------|-----------------------|----------------------------------------------------------------------|
| 01 | Illegal Function | FC not in the supported list (17, 20-23, 43, any undefined FC) |
| 02 | Illegal Data Address | Register outside `MB_HOLD_REG` / outside `MODBUSCP` param-block range |
| 03 | Illegal Data Value | Quantity exceeds spec (FC03/04 > 125, FC16 > 123, FC01/02 > 2000, FC15 > 1968) |
| 04 | Server Failure | Runtime error inside MB_SERVER (DB access fault, corrupt DB header, MB_SERVER disabled mid-request) [1][24] |
- **No proprietary exception codes (05/06/0A/0B) are used** on any S7
Modbus server [1][4]. Our driver's status-code mapper can treat these as
"never observed" on the S7 profile.
- **CPU in STOP → `MB_SERVER` keeps running if it's in OB1 of the firmware's
communication task, but OB1 itself is not scanned.** In practice:
- Holding-register *reads* (FC03) continue to return the last DB values
frozen at the moment the CPU entered STOP. The `MB_SERVER` block is in
OB1 so it isn't re-invoked; however the TCP stack keeps the socket open
and returns cached data on subsequent polls [1][21]. **Unconfirmed**
whether this is cached in the CP or in the CPU's communication processor;
behaviour varies between firmware 4.0 and 4.5 [21].
- Holding-register *writes* (FC06/FC16) during STOP return exception `04`
(Server Failure) on S7-1200 V4.2+, and return success-but-discarded on
older firmware [1][24]. Our driver should treat FC06/FC16 during STOP as
non-deterministic and not rely on the response code.
- Coil *writes* (FC05/FC15) to `%Q` are *accepted* by the process image
during STOP, but the physical output freezes at its last RUN-mode value
(or the configured STOP-mode substitute value) until RUN resumes [1][21].
- **Writing a read-only address via FC06/FC16**: returns `02` (Illegal Data
Address), not `04`. S7 does not have "write-protected" holding registers —
the programmer either exposes a DB for read-write or doesn't expose it at
all [1][12].
STATUS codes (returned in the `STATUS` output of the block, not on the wire):
- `0x0000` — no error.
- `0x7001` — first call, connection being established.
- `0x7002` — subsequent cyclic call, connection in progress.
- `0x8383` — data access error (optimized DB, DB too small, or type mismatch)
[10][24].
- `0x8188` — invalid parameter combination (e.g. MB_MODE out of range) [24].
- `0x80C8` — mismatched UNIT_ID between MB_CLIENT and `MB_SERVER` [25].
Test names:
`S7_1200_FC03_Outside_HoldReg_Returns_IllegalDataAddress`,
`S7_1200_FC16_In_STOP_Returns_ServerFailure`,
`S7_1200_FC03_In_STOP_Returns_Cached_Values`,
`S7_1200_No_Proprietary_ExceptionCodes_0x05_0x06_0x0A_0x0B`.
## Connection Behavior
- **Max simultaneous Modbus TCP connections**:
- **S7-1200**: shares a pool of 8 open-communication connections across
all TCP/UDP/Modbus use. On a CPU 1211C you get 8 total; on 1215C/1217C
still 8 shared among PG/HMI/OUC/Modbus. Each `MB_SERVER` instance
reserves one. A typical site with a PG + 1 HMI + 2 Modbus clients uses
4 of the 8 [1][26].
- **S7-1500**: up to **8 concurrent Modbus TCP server connections** per
`MB_SERVER` port, across multiple `MB_SERVER` instance DBs each with a
unique port. Total open-communication resources depend on CPU (e.g.
CPU 1515-2 PN supports 128 OUC connections total; Modbus is a subset)
[1][27].
- **CP 343-1 Lean**: up to **8** simultaneous Modbus TCP connections on
port 502 [4][5]. Exceeding this refuses at TCP accept.
- **CP 443-1 Advanced**: up to **16** simultaneous Modbus TCP connections
[4].
- **Multi-connection model on `MB_SERVER`**: one instance DB per connection.
An instance DB listening on port 502 serves exactly one connection at a
time; to serve N simultaneous clients you need N instance DBs each with a
unique port (502/503/504...). **This is a real trap** — most users expect
port 502 to multiplex [27][28]. Our driver must not assume port 502 is the
only listener.
- **Keep-alive**: S7-1500's TCP stack does send TCP keepalives (default
every ~30 s) but the interval is not exposed as a configurable. S7-1200 is
the same. CP 343-1 keepalives are configured via HW Config → CP properties
→ Options → "Send keepalive" (default **off** on older firmware, default
**on** on firmware V3.0+) [1][29]. Driver-side keepalive is still
advisable for S7-300/CP 343-1 on old firmware.
- **Idle-timeout close**: `MB_SERVER` does *not* close idle sockets on its
own. However, the TCP stack on S7-1500 will close a socket that fails
three consecutive keepalive probes (~2 minutes). Forum reports describe
`MB_SERVER` connections "dying overnight" on S7-1500 when an HMI stops
polling — the fix is to enable driver-side periodic reads or driver-side
TCP keepalive [29][30].
- **Reconnect after power cycle**: MB_SERVER starts listening ~1-2 seconds
after the CPU reaches RUN. If the client reconnects during STARTUP OB
(OB100), the connection is refused until OB1 runs the block at least once.
Our driver should back off and retry on `ECONNREFUSED` for the first 5
seconds after a power-cycle detection [1][24].
- **Unit Identifier**: `MB_SERVER` accepts **any** Unit ID by default — there
is no configurable filter; the PLC ignores the Unit ID field entirely.
`MB_CLIENT` defaults to Unit ID = 255 as "ignore" [25][31]. Some
third-party Modbus-TCP gateways *require* a specific Unit ID; sending
anything to S7 is safe. **CP 343-1 `MODBUSCP`** also accepts any Unit ID
in server mode, but the parameter DB exposes a `single_write` / `unit_id`
field on newer firmware to allow filtering [4].
Test names:
`S7_1200_9th_TCP_Connection_Refused_On_8_Conn_Pool`,
`S7_1500_Port_503_Required_For_Second_Instance`,
`S7_1200_Reconnect_After_Power_Cycle_Succeeds_Within_5s`,
`S7_1200_Unit_ID_Ignored_Any_Accepted`.
## Behavioral Oddities
- **Transaction ID echo** is reliable on all S7 variants. `MB_SERVER` copies
the MBAP TxId verbatim. No known firmware that drops TxId under load [1][31].
- **Request serialization**: a single `MB_SERVER` instance serializes
requests from its one connected client — the block processes one PDU per
call and calls happen once per OB1 scan. OB1 scan time of 5-50 ms puts an
upper bound on throughput at ~20-200 requests/sec per connection [1][30].
Multiple `MB_SERVER` instances (one per port) run in parallel because OB1
calls them sequentially within the same scan.
- **OB1 scan coupling**: `MB_SERVER` must be called cyclically from OB1 (or
another cyclic OB). If the programmer puts it in a conditional branch
that doesn't fire every scan, requests time out. The STATUS `0x7002`
"in progress" is *expected* between calls, not an error [1][24].
- **Optimized DB backing `MB_HOLD_REG`** — already covered in Address
Mapping; STATUS becomes `0x8383`. This is the most common deployment bug
on S7-1500 projects migrated from older S7-1200 examples [10][11].
- **CPU STOP behaviour** — covered in Exception Codes section. The short
version: reads may return stale data without error; writes return exception
04 on modern firmware.
- **Partial-frame disconnect**: S7-1200/1500 TCP stack closes the socket on
any MBAP header where the `Length` field doesn't match the PDU length.
Driver must detect half-close and reconnect [1][29].
- **MBAP `Protocol ID` must be 0**. Any non-zero value causes the CP/CPU to
drop the frame silently (no response, no RST) on S7-1500 firmware V2.0
through V2.9; firmware V3.0+ sends an RST [1][30]. *Unconfirmed* whether
V3.1 still sends RST or returns to silent drop.
- **FC01/FC02 access outside `%Q`/`%I` range**: on S7-1200, requesting
coil address 8192 (= `%Q1024.0`) returns exception `02` (Illegal Data
Address) [1][9]. The 8192-bit hard cap is a process-image size limit on
the CPU, not a Modbus protocol limit.
- **`MB_CLIENT` UNIT_ID mismatch with remote `MB_SERVER`** produces STATUS
`0x80C8` on the client side, and the server silently discards the frame
(no response on the wire) [25]. This matters for Modbus-TCP-to-RTU
gateway scenarios where the Unit ID picks the RTU slave.
- **Non-IEEE REAL / BCD**: S7 does *not* use BCD like DirectLOGIC. `Real` is
always IEEE 754 single-precision. `LReal` (8-byte double) occupies 4
Modbus registers in `ABCDEFGH` order (big-endian byte, big-endian word)
[15][18].
- **`MODBUSCP` single-write** on CP 343-1: a parameter `single_write` in the
param DB controls whether FC06 on a register in the "holding register"
area triggers a callback to the user program vs. updates the DB directly.
Default is direct update. If a ladder programmer enables the callback
without implementing the callback OB, FC06 writes hang for 5 seconds then
return exception `04` [4].
Test names:
`S7_1200_TxId_Preserved_Across_Burst_Of_50_Requests`,
`S7_1200_MBSERVER_Throughput_Capped_By_OB1_Scan`,
`S7_1200_MBAP_ProtocolID_NonZero_Frame_Dropped`,
`S7_1200_Partial_MBAP_Causes_Half_Close`.
## Model-specific Differences Worth Separate Test Coverage
- **S7-1200 V4.0 vs V4.4+**: Older firmware does not support `WString` over
`MB_HOLD_REG` and returns `0x8383` if the DB contains one [18][24]. Test
both firmware bands separately.
- **S7-1500 vs S7-1200**: S7-1500 supports multiple `MB_SERVER` instances on
the *same* CPU with different ports cleanly; S7-1200 can too but its
8-connection pool is shared tighter [1][27]. Throughput per-connection is
~5× faster on S7-1500 because the comms task runs on a dedicated core.
- **S7-300 + CP 343-1 vs S7-1200/1500**: parameter-block mapping (not
`MB_HOLD_REG` pointer), per-connection license, no `%Q`/`%I` direct
access for coils (everything goes through a DB), different STATUS codes
(`DONE`/`ERROR`/`STATUS` word pairs vs. the single STATUS word) [4][14].
Driver-side it's a different profile.
- **CP 343-1 Lean vs CP 343-1 Advanced**: Lean is server-only; Advanced is
client + server. Lean's max connections = 8; Advanced = 16 [4][5].
- **CP 443-1 in S7-400H**: uses `MODBUSCP_REDUNDANT` which presents two
Ethernet endpoints that fail over. Our driver's redundancy support should
recognize the S7-400H profile as "two IP addresses, same server state,
advertise via `ServerUriArray`" [6].
- **ET 200SP CPU (1510SP / 1512SP)**: behaves as S7-1500 from `MB_SERVER`
perspective. No known deltas [3].
## References
1. Siemens Industry Online Support, *Modbus/TCP Communication between SIMATIC S7-1500 / S7-1200 and Modbus/TCP Controllers with Instructions `MB_CLIENT` and `MB_SERVER`*, Entry ID 102020340, V6 (Feb 2021). https://cache.industry.siemens.com/dl/files/340/102020340/att_118119/v6/net_modbus_tcp_s7-1500_s7-1200_en.pdf
2. Siemens TIA Portal Online Docs, *MB_SERVER instruction*. https://docs.tia.siemens.cloud/r/simatic_s7_1200_manual_collection_eses_20/communication-processor-and-modbus-tcp/modbus-communication/modbus-tcp/modbus-tcp-instructions/mb_server-communicate-using-profinet-as-modbus-tcp-server-instruction
3. Siemens, *SIMATIC S7-1500 Communication Function Manual* (covers ET 200SP CPU). http://public.eandm.com/Public_Docs/s71500_communication_function_manual_en-US_en-US.pdf
4. Siemens Industry Online Support, *SIMATIC Modbus/TCP communication using CP 343-1 and CP 443-1 — Programming Manual*, Entry ID 103447617. https://cache.industry.siemens.com/dl/files/617/103447617/att_106971/v1/simatic_modbus_tcp_cp_en-US_en-US.pdf
5. Siemens Industry Online Support FAQ *"Which technical data applies for the SIMATIC Modbus/TCP software for CP 343-1 / CP 443-1?"*, Entry ID 104946406. https://www.industry-mobile-support.siemens-info.com/en/article/detail/104946406
6. Siemens Industry Online Support, *Redundant Modbus/TCP communication via CP 443-1 in S7-400H systems*, Entry ID 109739212. https://cache.industry.siemens.com/dl/files/212/109739212/att_887886/v1/SIMATIC_modbus_tcp_cp_red_e_en-US.pdf
7. Siemens Industry Online Support, *SIMATIC MODBUS (TCP) PN CPU Library — Programming and Operating Manual 06/2014*, Entry ID 75330636. https://support.industry.siemens.com/cs/attachments/75330636/ModbusTCPPNCPUen.pdf
8. DMC Inc., *Using an S7-1200 PLC as a Modbus TCP Slave*. https://www.dmcinfo.com/blog/27313/using-an-s7-1200-plc-as-a-modbus-tcp-slave/
9. Siemens, *SIMATIC S7-1200 System Manual* (V4.x), "MB_SERVER" pages 736-742. https://www.manualslib.com/manual/1453610/Siemens-S7-1200.html?page=736
10. lamaPLC, *Simatic Modbus S7 error- and statuscodes*. https://www.lamaplc.com/doku.php?id=simatic:errorcodes
11. ScadaProtocols, *How to Configure Modbus TCP on Siemens S7-1200 (TIA Portal Step-by-Step)*. https://scadaprotocols.com/modbus-tcp-siemens-s7-1200-tia-portal/
12. Industrial Monitor Direct, *Reading and Writing Memory Bits via Modbus TCP on S7-1200*. https://industrialmonitordirect.com/blogs/knowledgebase/reading-and-writing-memory-bits-via-modbus-tcp-on-s7-1200
13. PLCtalk forum *"Siemens S7-1200 modbus understanding"*. https://www.plctalk.net/forums/threads/siemens-s7-1200-modbus-understanding.104119/
14. Siemens SIMATIC S7 Manual, "Function block MODBUSCP — Functionality" (ManualsLib p29). https://www.manualslib.com/manual/1580661/Siemens-Simatic-S7.html?page=29
15. Chipkin, *How Real (Floating Point) and 32-bit Data is Encoded in Modbus*. https://store.chipkin.com/articles/how-real-floating-point-and-32-bit-data-is-encoded-in-modbus-rtu-messages
16. Siemens Industry Online Support forum, *MODBUS DATA conversion in S7-1200 CPU*, Entry ID 97287. https://support.industry.siemens.com/forum/WW/en/posts/modbus-data-converson-in-s7-1200-cpu/97287
17. Industrial Monitor Direct, *Siemens S7-1500 MB_SERVER Modbus TCP Configuration Guide*. https://industrialmonitordirect.com/de/blogs/knowledgebase/siemens-s7-1500-mb-server-modbus-tcp-configuration-guide
18. Siemens TIA Portal, *Data types in SIMATIC S7-1200/1500 — String/WString header layout* (system manual, "Elementary Data Types").
19. Kepware / PTC, *Siemens TCP/IP Ethernet Driver Help*, "Byte / Word Order" tag property. https://www.opcturkey.com/uploads/siemens-tcp-ip-ethernet-manual.pdf
20. Siemens SiePortal forum, *Transfer float out of words*, Entry ID 187811. https://sieportal.siemens.com/en-ww/support/forum/posts/transfer-float-out-of-words/187811 _(operator-reported "S7 swaps float" claim — traced to remote-device issue; **unconfirmed**.)_
21. Siemens SiePortal forum, *S7-1200 communication with Modbus TCP*, Entry ID 133086. https://support.industry.siemens.com/forum/WW/en/posts/s7-1200-communication-with-modbus-tcp/133086
22. Siemens SiePortal forum, *S7-1500 MB Server Holding Register Max Word*, Entry ID 224636. https://support.industry.siemens.com/forum/WW/en/posts/s7-1500-mb-server-holding-register-max-word/224636
23. Siemens, *SIMATIC S7-1500 Technical Specifications* — CPU-specific DB size limits in each CPU manual's "Memory" table.
24. Siemens TIA Portal Online Docs, *Error messages (S7-1200, S7-1500) — Modbus instructions*. https://docs.tia.siemens.cloud/r/en-us/v20/modbus-rtu-s7-1200-s7-1500/error-messages-s7-1200-s7-1500
25. Industrial Monitor Direct, *Fix Siemens S7-1500 MB_Client UnitID Error 80C8*. https://industrialmonitordirect.com/blogs/knowledgebase/troubleshooting-mb-client-on-s7-1500-cpu-1515sp-modbus-tcp
26. Siemens SiePortal forum, *How many TCP connections can the S7-1200 make?*, Entry ID 275570. https://support.industry.siemens.com/forum/WW/en/posts/how-many-tcp-connections-can-the-s7-1200-make/275570
27. Siemens SiePortal forum, *Simultaneous connections of Modbus TCP*, Entry ID 189626. https://support.industry.siemens.com/forum/ww/en/posts/simultaneous-connections-of-modbus-tcp/189626
28. Siemens SiePortal forum, *How many Modbus TCP IP clients can read simultaneously from S7-1517*, Entry ID 261569. https://support.industry.siemens.com/forum/WW/en/posts/how-many-modbus-tcp-ip-client-can-read-simultaneously-in-s7-1517/261569
29. Industrial Monitor Direct, *Troubleshooting Intermittent Modbus TCP Connections on S7-1500 PLC*. https://industrialmonitordirect.com/blogs/knowledgebase/troubleshooting-intermittent-modbus-tcp-connections-on-s7-1500-plc
30. PLCtalk forum *"S7-1500 modbus tcp speed?"*. https://www.plctalk.net/forums/threads/s7-1500-modbus-tcp-speed.114046/
31. Siemens SiePortal forum, *MB_Unit_ID parameter in Modbus TCP*, Entry ID 156635. https://support.industry.siemens.com/forum/WW/en/posts/mb-unit-id-parameter-in-modbus-tcp/156635

View File

@@ -71,4 +71,95 @@ public static class DirectLogicAddress
$"System V-memory offset {offsetWithinSystemBank} maps past 0xFFFF");
return (ushort)pdu;
}
// Bit-memory bases per DL260 user manual §I/O-configuration.
// Numbers after X / Y / C / SP are OCTAL in DirectLOGIC notation. The Modbus base is
// added to the octal-decoded offset; e.g. Y017 = Modbus coil 2048 + octal(17) = 2048 + 15 = 2063.
/// <summary>
/// DL260 Y-output coil base. Y0 octal → Modbus coil address 2048 (0-based).
/// </summary>
public const ushort YOutputBaseCoil = 2048;
/// <summary>
/// DL260 C-relay coil base. C0 octal → Modbus coil address 3072 (0-based).
/// </summary>
public const ushort CRelayBaseCoil = 3072;
/// <summary>
/// DL260 X-input discrete-input base. X0 octal → Modbus discrete input 0.
/// </summary>
public const ushort XInputBaseDiscrete = 0;
/// <summary>
/// DL260 SP special-relay discrete-input base. SP0 octal → Modbus discrete input 1024.
/// Read-only; writing SP relays is rejected with Illegal Data Address.
/// </summary>
public const ushort SpecialBaseDiscrete = 1024;
/// <summary>
/// Translate a DirectLOGIC Y-output address (e.g. <c>"Y0"</c>, <c>"Y17"</c>) to its
/// 0-based Modbus coil address on DL260. The trailing number is OCTAL, matching the
/// ladder-logic editor's notation.
/// </summary>
public static ushort YOutputToCoil(string yAddress) =>
AddOctalOffset(YOutputBaseCoil, StripPrefix(yAddress, 'Y'));
/// <summary>
/// Translate a DirectLOGIC C-relay address (e.g. <c>"C0"</c>, <c>"C1777"</c>) to its
/// 0-based Modbus coil address.
/// </summary>
public static ushort CRelayToCoil(string cAddress) =>
AddOctalOffset(CRelayBaseCoil, StripPrefix(cAddress, 'C'));
/// <summary>
/// Translate a DirectLOGIC X-input address (e.g. <c>"X0"</c>, <c>"X17"</c>) to its
/// 0-based Modbus discrete-input address. Reading an unpopulated X returns 0, not an
/// exception — the CPU sizes the table to configured I/O, not installed modules.
/// </summary>
public static ushort XInputToDiscrete(string xAddress) =>
AddOctalOffset(XInputBaseDiscrete, StripPrefix(xAddress, 'X'));
/// <summary>
/// Translate a DirectLOGIC SP-special-relay address (e.g. <c>"SP0"</c>) to its 0-based
/// Modbus discrete-input address. Accepts <c>"SP"</c> prefix case-insensitively.
/// </summary>
public static ushort SpecialToDiscrete(string spAddress)
{
if (string.IsNullOrWhiteSpace(spAddress))
throw new ArgumentException("SP address must not be empty", nameof(spAddress));
var s = spAddress.Trim();
if (s.Length >= 2 && (s[0] == 'S' || s[0] == 's') && (s[1] == 'P' || s[1] == 'p'))
s = s.Substring(2);
return AddOctalOffset(SpecialBaseDiscrete, s);
}
private static string StripPrefix(string address, char expectedPrefix)
{
if (string.IsNullOrWhiteSpace(address))
throw new ArgumentException("Address must not be empty", nameof(address));
var s = address.Trim();
if (s.Length > 0 && char.ToUpperInvariant(s[0]) == char.ToUpperInvariant(expectedPrefix))
s = s.Substring(1);
return s;
}
private static ushort AddOctalOffset(ushort baseAddr, string octalDigits)
{
if (octalDigits.Length == 0)
throw new ArgumentException("Address has no digits", nameof(octalDigits));
uint offset = 0;
foreach (var ch in octalDigits)
{
if (ch < '0' || ch > '7')
throw new ArgumentException(
$"Address contains non-octal digit '{ch}' — DirectLOGIC I/O addresses are octal (0-7)",
nameof(octalDigits));
offset = offset * 8 + (uint)(ch - '0');
}
var result = baseAddr + offset;
if (result > ushort.MaxValue)
throw new OverflowException($"Address {baseAddr}+{offset} exceeds 0xFFFF");
return (ushort)result;
}
}

View File

@@ -0,0 +1,161 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <summary>
/// Mitsubishi MELSEC PLC family selector for address-translation helpers. The Q/L/iQ-R
/// families write bit-device addresses (X, Y) in <b>hexadecimal</b> in GX Works and the
/// CPU manuals; the FX and iQ-F families write them in <b>octal</b> (same convention as
/// AutomationDirect DirectLOGIC). Mixing the two up is the #1 MELSEC driver bug source —
/// an operator typing <c>X20</c> into a Q-series tag config means decimal 32, but the
/// same string on an FX3U means decimal 16, so the helper must know the family to route
/// correctly.
/// </summary>
public enum MelsecFamily
{
/// <summary>
/// MELSEC-Q / MELSEC-L / MELSEC iQ-R. X and Y device numbers are interpreted as
/// <b>hexadecimal</b>; <c>X20</c> means decimal 32.
/// </summary>
Q_L_iQR,
/// <summary>
/// MELSEC-F (FX3U / FX3GE / FX3G) and MELSEC iQ-F (FX5U). X and Y device numbers
/// are interpreted as <b>octal</b> (same as DirectLOGIC); <c>X20</c> means decimal 16.
/// iQ-F has a GX Works3 project toggle that can flip to decimal — if a site uses
/// that, configure the tag's Address directly as a decimal PDU address and do not
/// route through this helper.
/// </summary>
F_iQF,
}
/// <summary>
/// Mitsubishi MELSEC address-translation helpers for the QJ71MT91 / LJ71MT91 / RJ71EN71 /
/// iQ-R built-in / iQ-F / FX3U-ENET-P502 Modbus modules. MELSEC does NOT hard-wire
/// Modbus-to-device mappings like DL260 does — every site configures its own "Modbus
/// Device Assignment Parameter" block of up to 16 entries. The helpers here cover only
/// the <b>address-notation</b> portion of the translation (hex X20 vs octal X20 + adding
/// the bank base); the caller is still responsible for knowing the assignment-block
/// offset for their site.
/// </summary>
/// <remarks>
/// See <c>docs/v2/mitsubishi.md</c> §device-assignment + §X-Y-hex-trap for the full
/// matrix and primary-source citations.
/// </remarks>
public static class MelsecAddress
{
/// <summary>
/// Translate a MELSEC X-input address (e.g. <c>"X0"</c>, <c>"X10"</c>) to a 0-based
/// Modbus discrete-input address, given the PLC family's address notation (hex or
/// octal) and the Modbus Device Assignment block's X-range base.
/// </summary>
/// <param name="xAddress">MELSEC X address. <c>X</c> prefix optional, case-insensitive.</param>
/// <param name="family">The PLC family — determines whether the trailing digits are hex or octal.</param>
/// <param name="xBankBase">
/// 0-based Modbus DI address the assignment-block has configured X0 to land at.
/// Typical default on QJ71MT91 sample projects: 0. Pass the site-specific value.
/// </param>
public static ushort XInputToDiscrete(string xAddress, MelsecFamily family, ushort xBankBase = 0) =>
AddFamilyOffset(xBankBase, StripPrefix(xAddress, 'X'), family);
/// <summary>
/// Translate a MELSEC Y-output address to a 0-based Modbus coil address. Same rules
/// as <see cref="XInputToDiscrete"/> for hex/octal parsing.
/// </summary>
public static ushort YOutputToCoil(string yAddress, MelsecFamily family, ushort yBankBase = 0) =>
AddFamilyOffset(yBankBase, StripPrefix(yAddress, 'Y'), family);
/// <summary>
/// Translate a MELSEC M-relay address (internal relay) to a 0-based Modbus coil
/// address. M-addresses are <b>decimal</b> on every MELSEC family — unlike X/Y which
/// are hex on Q/L/iQ-R. Includes the bank base that the assignment-block configured.
/// </summary>
public static ushort MRelayToCoil(string mAddress, ushort mBankBase = 0)
{
var digits = StripPrefix(mAddress, 'M');
if (!ushort.TryParse(digits, out var offset))
throw new ArgumentException(
$"M-relay address '{mAddress}' is not a valid decimal integer", nameof(mAddress));
var result = mBankBase + offset;
if (result > ushort.MaxValue)
throw new OverflowException($"M-relay {mAddress} + base {mBankBase} exceeds 0xFFFF");
return (ushort)result;
}
/// <summary>
/// Translate a MELSEC D-register address (data register) to a 0-based Modbus holding
/// register address. D-addresses are <b>decimal</b>. Default assignment convention is
/// D0 → HR 0 (pass <paramref name="dBankBase"/> = 0); sites with shifted layouts pass
/// their configured base.
/// </summary>
public static ushort DRegisterToHolding(string dAddress, ushort dBankBase = 0)
{
var digits = StripPrefix(dAddress, 'D');
if (!ushort.TryParse(digits, out var offset))
throw new ArgumentException(
$"D-register address '{dAddress}' is not a valid decimal integer", nameof(dAddress));
var result = dBankBase + offset;
if (result > ushort.MaxValue)
throw new OverflowException($"D-register {dAddress} + base {dBankBase} exceeds 0xFFFF");
return (ushort)result;
}
private static string StripPrefix(string address, char expectedPrefix)
{
if (string.IsNullOrWhiteSpace(address))
throw new ArgumentException("Address must not be empty", nameof(address));
var s = address.Trim();
if (s.Length > 0 && char.ToUpperInvariant(s[0]) == char.ToUpperInvariant(expectedPrefix))
s = s.Substring(1);
if (s.Length == 0)
throw new ArgumentException($"Address '{address}' has no digits after prefix", nameof(address));
return s;
}
private static ushort AddFamilyOffset(ushort baseAddr, string digits, MelsecFamily family)
{
uint offset = family switch
{
MelsecFamily.Q_L_iQR => ParseHex(digits),
MelsecFamily.F_iQF => ParseOctal(digits),
_ => throw new ArgumentOutOfRangeException(nameof(family), family, "Unknown MELSEC family"),
};
var result = baseAddr + offset;
if (result > ushort.MaxValue)
throw new OverflowException($"Address {baseAddr}+{offset} exceeds 0xFFFF");
return (ushort)result;
}
private static uint ParseHex(string digits)
{
uint result = 0;
foreach (var ch in digits)
{
uint nibble;
if (ch >= '0' && ch <= '9') nibble = (uint)(ch - '0');
else if (ch >= 'A' && ch <= 'F') nibble = (uint)(ch - 'A' + 10);
else if (ch >= 'a' && ch <= 'f') nibble = (uint)(ch - 'a' + 10);
else throw new ArgumentException(
$"Address contains non-hex digit '{ch}' — Q/L/iQ-R X/Y addresses are hexadecimal",
nameof(digits));
result = result * 16 + nibble;
if (result > ushort.MaxValue)
throw new OverflowException($"Hex address exceeds 0xFFFF");
}
return result;
}
private static uint ParseOctal(string digits)
{
uint result = 0;
foreach (var ch in digits)
{
if (ch < '0' || ch > '7')
throw new ArgumentException(
$"Address contains non-octal digit '{ch}' — FX/iQ-F X/Y addresses are octal (0-7)",
nameof(digits));
result = result * 8 + (uint)(ch - '0');
if (result > ushort.MaxValue)
throw new OverflowException($"Octal address exceeds 0xFFFF");
}
return result;
}
}

View File

@@ -37,7 +37,7 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
private CancellationTokenSource? _probeCts;
private readonly ModbusDriverOptions _options = options;
private readonly Func<ModbusDriverOptions, IModbusTransport> _transportFactory =
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout));
transportFactory ?? (o => new ModbusTcpTransport(o.Host, o.Port, o.Timeout, o.AutoReconnect));
private IModbusTransport? _transport;
private DriverHealth _health = new(DriverState.Unknown, null, null);
@@ -141,9 +141,16 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
results[i] = new DataValueSnapshot(value, 0u, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (ModbusException mex)
{
results[i] = new DataValueSnapshot(null, MapModbusExceptionToStatus(mex.ExceptionCode), null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, mex.Message);
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, StatusBadInternalError, null, now);
// Non-Modbus-layer failure: socket dropped, timeout, malformed response. Surface
// as communication error so callers can distinguish it from tag-level faults.
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
@@ -171,11 +178,14 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
{
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]);
// Auto-chunk when the tag's register span exceeds the caller-configured cap.
// Affects long strings (FC03/04 > 125 regs is spec-forbidden; DL205 caps at 128,
// Mitsubishi Q caps at 64). Non-string tags max out at 4 regs so the cap never
// triggers for numerics.
var cap = _options.MaxRegistersPerRead == 0 ? (ushort)125 : _options.MaxRegistersPerRead;
var data = quantity <= cap
? await ReadRegisterBlockAsync(transport, fc, tag.Address, quantity, ct).ConfigureAwait(false)
: await ReadRegisterBlockChunkedAsync(transport, fc, tag.Address, quantity, cap, ct).ConfigureAwait(false);
return DecodeRegister(data, tag);
}
default:
@@ -183,6 +193,33 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
}
}
private async Task<byte[]> ReadRegisterBlockAsync(
IModbusTransport transport, byte fc, ushort address, ushort quantity, CancellationToken ct)
{
var pdu = new byte[] { fc, (byte)(address >> 8), (byte)(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 byte[resp[1]];
Buffer.BlockCopy(resp, 2, data, 0, resp[1]);
return data;
}
private async Task<byte[]> ReadRegisterBlockChunkedAsync(
IModbusTransport transport, byte fc, ushort address, ushort totalRegs, ushort cap, CancellationToken ct)
{
var assembled = new byte[totalRegs * 2];
ushort done = 0;
while (done < totalRegs)
{
var chunk = (ushort)Math.Min(cap, totalRegs - done);
var chunkBytes = await ReadRegisterBlockAsync(transport, fc, (ushort)(address + done), chunk, ct).ConfigureAwait(false);
Buffer.BlockCopy(chunkBytes, 0, assembled, done * 2, chunkBytes.Length);
done += chunk;
}
return assembled;
}
// ---- IWritable ----
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
@@ -208,6 +245,10 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
await WriteOneAsync(transport, tag, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(0u);
}
catch (ModbusException mex)
{
results[i] = new WriteResult(MapModbusExceptionToStatus(mex.ExceptionCode));
}
catch (Exception)
{
results[i] = new WriteResult(StatusBadInternalError);
@@ -239,8 +280,13 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
}
else
{
// FC 16 (Write Multiple Registers) for 32-bit types
// FC 16 (Write Multiple Registers) for 32-bit types.
var qty = (ushort)(bytes.Length / 2);
var writeCap = _options.MaxRegistersPerWrite == 0 ? (ushort)123 : _options.MaxRegistersPerWrite;
if (qty > writeCap)
throw new InvalidOperationException(
$"Write of {qty} registers to {tag.Name} exceeds MaxRegistersPerWrite={writeCap}. " +
$"Split the tag (e.g. shorter StringLength) — partial FC16 chunks would lose atomicity.");
var pdu = new byte[6 + 1 + bytes.Length];
pdu[0] = 0x10;
pdu[1] = (byte)(tag.Address >> 8); pdu[2] = (byte)(tag.Address & 0xFF);
@@ -651,6 +697,31 @@ public sealed class ModbusDriver(ModbusDriverOptions options, string driverInsta
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadNodeIdUnknown = 0x80340000u;
private const uint StatusBadNotWritable = 0x803B0000u;
private const uint StatusBadOutOfRange = 0x803C0000u;
private const uint StatusBadNotSupported = 0x803D0000u;
private const uint StatusBadDeviceFailure = 0x80550000u;
private const uint StatusBadCommunicationError = 0x80050000u;
/// <summary>
/// Map a server-returned Modbus exception code to the most informative OPC UA
/// StatusCode. Keeps the driver's outward-facing status surface aligned with what a
/// Modbus engineer would expect when reading the spec: exception 02 (Illegal Data
/// Address) surfaces as BadOutOfRange so clients can distinguish "tag wrong" from
/// generic BadInternalError, exception 04 (Server Failure) as BadDeviceFailure so
/// operators see a CPU-mode problem rather than a driver bug, etc. Per
/// <c>docs/v2/dl205.md</c>, DL205/DL260 returns only codes 01-04 — no proprietary
/// extensions.
/// </summary>
internal static uint MapModbusExceptionToStatus(byte exceptionCode) => exceptionCode switch
{
0x01 => StatusBadNotSupported, // Illegal Function — FC not in supported list
0x02 => StatusBadOutOfRange, // Illegal Data Address — register outside mapped range
0x03 => StatusBadOutOfRange, // Illegal Data Value — quantity over per-FC cap
0x04 => StatusBadDeviceFailure, // Server Failure — CPU in PROGRAM mode during protected write
0x05 or 0x06 => StatusBadDeviceFailure, // Acknowledge / Server Busy — long-running op / busy
0x0A or 0x0B => StatusBadCommunicationError, // Gateway path unavailable / target failed to respond
_ => StatusBadInternalError,
};
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()

View File

@@ -25,6 +25,37 @@ public sealed class ModbusDriverOptions
/// <see cref="IHostConnectivityProbe"/>.
/// </summary>
public ModbusProbeOptions Probe { get; init; } = new();
/// <summary>
/// Maximum registers per FC03 (Read Holding Registers) / FC04 (Read Input Registers)
/// transaction. Modbus-TCP spec allows 125; many device families impose lower caps:
/// AutomationDirect DL205/DL260 cap at <c>128</c>, Mitsubishi Q/FX3U cap at <c>64</c>,
/// Omron CJ/CS cap at <c>125</c>. Set to the lowest cap across the devices this driver
/// instance talks to; the driver auto-chunks larger reads into consecutive requests.
/// Default <c>125</c> — the spec maximum, safe against any conforming server. Setting
/// to <c>0</c> disables the cap (discouraged — the spec upper bound still applies).
/// </summary>
public ushort MaxRegistersPerRead { get; init; } = 125;
/// <summary>
/// Maximum registers per FC16 (Write Multiple Registers) transaction. Spec maximum is
/// <c>123</c>; DL205/DL260 cap at <c>100</c>. Matching caller-vs-device semantics:
/// exceeding the cap currently throws (writes aren't auto-chunked because a partial
/// write across two FC16 calls is no longer atomic — caller must explicitly opt in
/// by shortening the tag's <c>StringLength</c> or splitting it into multiple tags).
/// </summary>
public ushort MaxRegistersPerWrite { get; init; } = 123;
/// <summary>
/// When <c>true</c> (default) the built-in <see cref="ModbusTcpTransport"/> detects
/// mid-transaction socket failures (<see cref="System.IO.EndOfStreamException"/>,
/// <see cref="System.Net.Sockets.SocketException"/>) and transparently reconnects +
/// retries the PDU exactly once. Required for DL205/DL260 because the H2-ECOM100
/// does not send TCP keepalives — intermediate NAT / firewall devices silently close
/// idle sockets and the first send after the drop would otherwise surface as a
/// connection error to the caller even though the PLC is up.
/// </summary>
public bool AutoReconnect { get; init; } = true;
}
public sealed class ModbusProbeOptions

View File

@@ -8,22 +8,40 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// support concurrent transactions, but the single-flight model keeps the wire trace
/// easy to diagnose and avoids interleaved-response correlation bugs.
/// </summary>
/// <remarks>
/// <para>
/// Survives mid-transaction socket drops: when a send/read fails with a socket-level
/// error (<see cref="IOException"/>, <see cref="SocketException"/>, <see cref="EndOfStreamException"/>)
/// the transport disposes the dead socket, reconnects, and retries the PDU exactly
/// once. Deliberately limited to a single retry — further failures bubble up so the
/// driver's health surface reflects the real state instead of masking a dead PLC.
/// </para>
/// <para>
/// Why this matters for DL205/DL260: the AutomationDirect H2-ECOM100 does NOT send
/// TCP keepalives per <c>docs/v2/dl205.md</c> §behavioral-oddities, so any NAT/firewall
/// between the gateway and PLC can silently close an idle socket after 2-5 minutes.
/// Also enables OS-level <c>SO_KEEPALIVE</c> so the driver's own side detects a stuck
/// socket in reasonable time even when the application is mostly idle.
/// </para>
/// </remarks>
public sealed class ModbusTcpTransport : IModbusTransport
{
private readonly string _host;
private readonly int _port;
private readonly TimeSpan _timeout;
private readonly bool _autoReconnect;
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)
public ModbusTcpTransport(string host, int port, TimeSpan timeout, bool autoReconnect = true)
{
_host = host;
_port = port;
_timeout = timeout;
_autoReconnect = autoReconnect;
}
public async Task ConnectAsync(CancellationToken ct)
@@ -39,12 +57,34 @@ public sealed class ModbusTcpTransport : IModbusTransport
var target = ipv4 ?? (addresses.Length > 0 ? addresses[0] : System.Net.IPAddress.Loopback);
_client = new TcpClient(target.AddressFamily);
EnableKeepAlive(_client);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(_timeout);
await _client.ConnectAsync(target, _port, cts.Token).ConfigureAwait(false);
_stream = _client.GetStream();
}
/// <summary>
/// Enable SO_KEEPALIVE with aggressive probe timing. DL205/DL260 doesn't send keepalives
/// itself; having the OS probe the socket every ~30s lets the driver notice a dead PLC
/// or broken NAT path long before the default 2-hour Windows idle timeout fires.
/// Non-fatal if the underlying OS rejects the option (some older Linux / container
/// sandboxes don't expose the fine-grained timing levers — the driver still works,
/// application-level probe still detects problems).
/// </summary>
private static void EnableKeepAlive(TcpClient client)
{
try
{
client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30);
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10);
client.Client.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3);
}
catch { /* best-effort; older OSes may not expose the granular knobs */ }
}
public async Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_disposed) throw new ObjectDisposedException(nameof(ModbusTcpTransport));
@@ -53,43 +93,18 @@ public sealed class ModbusTcpTransport : IModbusTransport
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)
try
{
var fc = (byte)(respPdu[0] & 0x7F);
var ex = respPdu[1];
throw new ModbusException(fc, ex, $"Modbus exception fc={fc} code={ex}");
return await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
}
catch (Exception ex) when (_autoReconnect && IsSocketLevelFailure(ex))
{
// Mid-transaction drop: tear down the dead socket, reconnect, resend. Single
// retry — if it fails again, let it propagate so health/status reflect reality.
await TearDownAsync().ConfigureAwait(false);
await ConnectAsync(ct).ConfigureAwait(false);
return await SendOnceAsync(unitId, pdu, ct).ConfigureAwait(false);
}
return respPdu;
}
finally
{
@@ -97,6 +112,68 @@ public sealed class ModbusTcpTransport : IModbusTransport
}
}
private async Task<byte[]> SendOnceAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (_stream is null) throw new InvalidOperationException("Transport not connected");
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;
}
/// <summary>
/// Distinguish socket-layer failures (eligible for reconnect-and-retry) from
/// protocol-layer failures (must propagate — retrying the same PDU won't help if the
/// PLC just returned exception 02 Illegal Data Address).
/// </summary>
private static bool IsSocketLevelFailure(Exception ex) =>
ex is EndOfStreamException
|| ex is IOException
|| ex is SocketException
|| ex is ObjectDisposedException;
private async Task TearDownAsync()
{
try { if (_stream is not null) await _stream.DisposeAsync().ConfigureAwait(false); }
catch { /* best-effort */ }
_stream = null;
try { _client?.Dispose(); } catch { }
_client = null;
}
private static async Task ReadExactlyAsync(Stream s, byte[] buf, CancellationToken ct)
{
var read = 0;

View File

@@ -0,0 +1,721 @@
using Opc.Ua;
using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// OPC UA Client (gateway) driver. Opens a <see cref="Session"/> against a remote OPC UA
/// server and re-exposes its address space through the local OtOpcUa server. PR 66 ships
/// the scaffold: <see cref="IDriver"/> only (connect / close / health). Browse, read,
/// write, subscribe, and probe land in PRs 67-69.
/// </summary>
/// <remarks>
/// <para>
/// Builds its own <see cref="ApplicationConfiguration"/> rather than reusing
/// <c>Client.Shared</c> — Client.Shared is oriented at the interactive CLI; this
/// driver is an always-on service component with different session-lifetime needs
/// (keep-alive monitor, session transfer on reconnect, multi-year uptime).
/// </para>
/// <para>
/// <b>Session lifetime</b>: a single <see cref="Session"/> per driver instance.
/// Subscriptions multiplex onto that session; SDK reconnect handler takes the session
/// down and brings it back up on remote-server restart — the driver must re-send
/// subscriptions + TransferSubscriptions on reconnect to avoid dangling
/// monitored-item handles. That mechanic lands in PR 69.
/// </para>
/// </remarks>
public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId)
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
{
// ---- ISubscribable + IHostConnectivityProbe state ----
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, RemoteSubscription> _subscriptions = new();
private long _nextSubscriptionId;
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private KeepAliveEventHandler? _keepAliveHandler;
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
// OPC UA StatusCode constants the driver surfaces for local-side faults. Upstream-server
// StatusCodes are passed through verbatim per driver-specs.md §8 "cascading quality" —
// downstream clients need to distinguish 'remote source down' from 'local driver failure'.
private const uint StatusBadNodeIdInvalid = 0x80330000u;
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadCommunicationError = 0x80050000u;
private readonly OpcUaClientDriverOptions _options = options;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>Active OPC UA session. Null until <see cref="InitializeAsync"/> returns cleanly.</summary>
internal ISession? Session { get; private set; }
/// <summary>Per-connection gate. PRs 67+ serialize read/write/browse on this.</summary>
internal SemaphoreSlim Gate => _gate;
private DriverHealth _health = new(DriverState.Unknown, null, null);
private bool _disposed;
public string DriverInstanceId => driverInstanceId;
public string DriverType => "OpcUaClient";
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false);
// Endpoint selection: let the stack pick the best matching endpoint for the
// requested security policy/mode so the driver doesn't have to hand-validate.
// UseSecurity=false when SecurityMode=None shortcuts around cert validation
// entirely and is the typical dev-bench configuration.
var selected = await SelectMatchingEndpointAsync(
appConfig, _options.EndpointUrl, _options.SecurityPolicy, _options.SecurityMode,
cancellationToken).ConfigureAwait(false);
var endpointConfig = EndpointConfiguration.Create(appConfig);
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig);
var identity = _options.AuthType switch
{
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
// The UserIdentity(string, string) overload was removed in favour of
// (string, byte[]) to make the password encoding explicit. UTF-8 is the
// overwhelmingly common choice for Basic256Sha256-secured sessions.
OpcUaAuthType.Username => new UserIdentity(
_options.Username ?? string.Empty,
System.Text.Encoding.UTF8.GetBytes(_options.Password ?? string.Empty)),
OpcUaAuthType.Certificate => throw new NotSupportedException(
"Certificate authentication lands in a follow-up PR; for now use Anonymous or Username"),
_ => new UserIdentity(new AnonymousIdentityToken()),
};
// All Session.Create* static methods are marked [Obsolete] in SDK 1.5.378; the
// non-obsolete path is DefaultSessionFactory.Instance.CreateAsync (which is the
// 8-arg signature matching our driver config — ApplicationConfiguration +
// ConfiguredEndpoint, no transport-waiting-connection or reverse-connect-manager
// required for the standard opc.tcp direct-connect case).
// DefaultSessionFactory's parameterless ctor is also obsolete in 1.5.378; the
// current constructor requires an ITelemetryContext. Passing null is tolerated —
// the factory falls back to its internal default sink, same as the telemetry:null
// on SelectEndpointAsync above.
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
appConfig,
endpoint,
false, // updateBeforeConnect
_options.SessionName,
(uint)_options.SessionTimeout.TotalMilliseconds,
identity,
null, // preferredLocales
cancellationToken).ConfigureAwait(false);
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
// Wire the session's keep-alive channel into HostState. OPC UA keep-alives are
// authoritative for session liveness: the SDK pings on KeepAliveInterval and sets
// KeepAliveStopped when N intervals elapse without a response. That's strictly
// better than a driver-side polling probe — no extra round-trip, no duplicate
// semantic.
_keepAliveHandler = (_, e) =>
{
var healthy = !ServiceResult.IsBad(e.Status);
TransitionTo(healthy ? HostState.Running : HostState.Stopped);
};
session.KeepAlive += _keepAliveHandler;
Session = session;
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
TransitionTo(HostState.Running);
}
catch (Exception ex)
{
try { if (Session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { }
Session = null;
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
}
/// <summary>
/// Build a minimal in-memory <see cref="ApplicationConfiguration"/>. Certificates live
/// under the OS user profile — on Windows that's <c>%LocalAppData%\OtOpcUa\pki</c>
/// — so multiple driver instances in the same OtOpcUa server process share one
/// certificate store without extra config.
/// </summary>
private async Task<ApplicationConfiguration> BuildApplicationConfigurationAsync(CancellationToken ct)
{
// The default ctor is obsolete in favour of the ITelemetryContext overload; suppress
// locally rather than plumbing a telemetry context all the way through the driver
// surface — the driver emits no per-request telemetry of its own and the SDK's
// internal fallback is fine for a gateway use case.
#pragma warning disable CS0618
var app = new ApplicationInstance
{
ApplicationName = _options.SessionName,
ApplicationType = ApplicationType.Client,
};
#pragma warning restore CS0618
var pkiRoot = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OtOpcUa", "pki");
var config = new ApplicationConfiguration
{
ApplicationName = _options.SessionName,
ApplicationType = ApplicationType.Client,
ApplicationUri = _options.ApplicationUri,
SecurityConfiguration = new SecurityConfiguration
{
ApplicationCertificate = new CertificateIdentifier
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "own"),
SubjectName = $"CN={_options.SessionName}",
},
TrustedPeerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "trusted"),
},
TrustedIssuerCertificates = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "issuers"),
},
RejectedCertificateStore = new CertificateTrustList
{
StoreType = CertificateStoreType.Directory,
StorePath = Path.Combine(pkiRoot, "rejected"),
},
AutoAcceptUntrustedCertificates = _options.AutoAcceptCertificates,
},
TransportQuotas = new TransportQuotas { OperationTimeout = (int)_options.Timeout.TotalMilliseconds },
ClientConfiguration = new ClientConfiguration
{
DefaultSessionTimeout = (int)_options.SessionTimeout.TotalMilliseconds,
},
DisableHiResClock = true,
};
await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false);
// Attach a cert-validator handler that honours the AutoAccept flag. Without this,
// AutoAcceptUntrustedCertificates on the config alone isn't always enough in newer
// SDK versions — the validator raises an event the app has to handle.
if (_options.AutoAcceptCertificates)
{
config.CertificateValidator.CertificateValidation += (s, e) =>
{
if (e.Error.StatusCode == StatusCodes.BadCertificateUntrusted)
e.Accept = true;
};
}
// Ensure an application certificate exists. The SDK auto-generates one if missing.
app.ApplicationConfiguration = config;
await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct)
.ConfigureAwait(false);
return config;
}
/// <summary>
/// Select the remote endpoint matching both the requested <paramref name="policy"/>
/// and <paramref name="mode"/>. The SDK's <c>CoreClientUtils.SelectEndpointAsync</c>
/// only honours a boolean "use security" flag; we need policy-aware matching so an
/// operator asking for <c>Basic256Sha256</c> against a server that also offers
/// <c>Basic128Rsa15</c> doesn't silently end up on the weaker cipher.
/// </summary>
private static async Task<EndpointDescription> SelectMatchingEndpointAsync(
ApplicationConfiguration appConfig,
string endpointUrl,
OpcUaSecurityPolicy policy,
OpcUaSecurityMode mode,
CancellationToken ct)
{
// GetEndpoints returns everything the server advertises; policy + mode filter is
// applied client-side so the selection is explicit and fails loudly if the operator
// asks for a combination the server doesn't publish. DiscoveryClient.CreateAsync
// is the non-obsolete path in SDK 1.5.378; the synchronous Create(..) variants are
// all deprecated.
using var client = await DiscoveryClient.CreateAsync(
appConfig, new Uri(endpointUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false);
var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false);
var wantedPolicyUri = MapSecurityPolicy(policy);
var wantedMode = mode switch
{
OpcUaSecurityMode.None => MessageSecurityMode.None,
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
_ => throw new ArgumentOutOfRangeException(nameof(mode)),
};
var match = all.FirstOrDefault(e =>
e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode);
if (match is null)
{
var advertised = string.Join(", ", all
.Select(e => $"{ShortPolicyName(e.SecurityPolicyUri)}/{e.SecurityMode}"));
throw new InvalidOperationException(
$"No endpoint at '{endpointUrl}' matches SecurityPolicy={policy} + SecurityMode={mode}. " +
$"Server advertises: {advertised}");
}
return match;
}
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch
{
OpcUaSecurityPolicy.None => SecurityPolicies.None,
OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15,
OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256,
OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256,
OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep,
OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss,
_ => throw new ArgumentOutOfRangeException(nameof(policy), policy, null),
};
private static string ShortPolicyName(string policyUri) =>
policyUri?.Substring(policyUri.LastIndexOf('#') + 1) ?? "(null)";
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
// Tear down remote subscriptions first — otherwise Session.Close will try and may fail
// with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared
// whether or not the wire-side delete succeeds since the local handles are useless
// after close anyway.
foreach (var rs in _subscriptions.Values)
{
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
}
_subscriptions.Clear();
if (_keepAliveHandler is not null && Session is not null)
{
try { Session.KeepAlive -= _keepAliveHandler; } catch { }
}
_keepAliveHandler = null;
try { if (Session is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
try { Session?.Dispose(); } catch { }
Session = null;
TransitionTo(HostState.Unknown);
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
public DriverHealth GetHealth() => _health;
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- IReadable ----
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var session = RequireSession();
var results = new DataValueSnapshot[fullReferences.Count];
var now = DateTime.UtcNow;
// Parse NodeIds up-front. Tags whose reference doesn't parse get BadNodeIdInvalid
// and are omitted from the wire request — saves a round-trip against the upstream
// server for a fault the driver can detect locally.
var toSend = new ReadValueIdCollection();
var indexMap = new List<int>(fullReferences.Count); // maps wire-index -> results-index
for (var i = 0; i < fullReferences.Count; i++)
{
if (!TryParseNodeId(session, fullReferences[i], out var nodeId))
{
results[i] = new DataValueSnapshot(null, StatusBadNodeIdInvalid, null, now);
continue;
}
toSend.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value });
indexMap.Add(i);
}
if (toSend.Count == 0) return results;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
var resp = await session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Both,
nodesToRead: toSend,
ct: cancellationToken).ConfigureAwait(false);
var values = resp.Results;
for (var w = 0; w < values.Count; w++)
{
var r = indexMap[w];
var dv = values[w];
// Preserve the upstream StatusCode verbatim — including Bad codes per
// §8's cascading-quality rule. Also preserve SourceTimestamp so downstream
// clients can detect stale upstream data.
results[r] = new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp);
}
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (Exception ex)
{
// Transport / timeout / session-dropped — fan out the same fault across every
// tag in this batch. Per-tag StatusCode stays BadCommunicationError (not
// BadInternalError) so operators distinguish "upstream unreachable" from
// "driver bug".
for (var w = 0; w < indexMap.Count; w++)
{
var r = indexMap[w];
results[r] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
}
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
finally { _gate.Release(); }
return results;
}
// ---- IWritable ----
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<Core.Abstractions.WriteRequest> writes, CancellationToken cancellationToken)
{
var session = RequireSession();
var results = new WriteResult[writes.Count];
var toSend = new WriteValueCollection();
var indexMap = new List<int>(writes.Count);
for (var i = 0; i < writes.Count; i++)
{
if (!TryParseNodeId(session, writes[i].FullReference, out var nodeId))
{
results[i] = new WriteResult(StatusBadNodeIdInvalid);
continue;
}
toSend.Add(new WriteValue
{
NodeId = nodeId,
AttributeId = Attributes.Value,
Value = new DataValue(new Variant(writes[i].Value)),
});
indexMap.Add(i);
}
if (toSend.Count == 0) return results;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try
{
var resp = await session.WriteAsync(
requestHeader: null,
nodesToWrite: toSend,
ct: cancellationToken).ConfigureAwait(false);
var codes = resp.Results;
for (var w = 0; w < codes.Count; w++)
{
var r = indexMap[w];
// Pass upstream WriteResult StatusCode through verbatim. Success codes
// include Good (0) and any warning-level Good* variants; anything with
// the severity bits set is a Bad.
results[r] = new WriteResult(codes[w].Code);
}
}
catch (Exception)
{
for (var w = 0; w < indexMap.Count; w++)
results[indexMap[w]] = new WriteResult(StatusBadCommunicationError);
}
}
finally { _gate.Release(); }
return results;
}
/// <summary>
/// Parse a tag's full-reference string as a NodeId. Accepts the standard OPC UA
/// serialized forms (<c>ns=2;s=…</c>, <c>i=2253</c>, <c>ns=4;g=…</c>, <c>ns=3;b=…</c>).
/// Empty + malformed strings return false; the driver surfaces that as
/// <see cref="StatusBadNodeIdInvalid"/> without a wire round-trip.
/// </summary>
internal static bool TryParseNodeId(ISession session, string fullReference, out NodeId nodeId)
{
nodeId = NodeId.Null;
if (string.IsNullOrWhiteSpace(fullReference)) return false;
try
{
nodeId = NodeId.Parse(session.MessageContext, fullReference);
return !NodeId.IsNull(nodeId);
}
catch
{
return false;
}
}
private ISession RequireSession() =>
Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized");
// ---- ITagDiscovery ----
public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var session = RequireSession();
var root = !string.IsNullOrEmpty(_options.BrowseRoot)
? NodeId.Parse(session.MessageContext, _options.BrowseRoot)
: ObjectIds.ObjectsFolder;
var rootFolder = builder.Folder("Remote", "Remote");
var visited = new HashSet<NodeId>();
var discovered = 0;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await BrowseRecursiveAsync(session, root, rootFolder, visited,
depth: 0, discovered: () => discovered, increment: () => discovered++,
ct: cancellationToken).ConfigureAwait(false);
}
finally { _gate.Release(); }
}
private async Task BrowseRecursiveAsync(
ISession session, NodeId node, IAddressSpaceBuilder folder, HashSet<NodeId> visited,
int depth, Func<int> discovered, Action increment, CancellationToken ct)
{
if (depth >= _options.MaxBrowseDepth) return;
if (discovered() >= _options.MaxDiscoveredNodes) return;
if (!visited.Add(node)) return;
var browseDescriptions = new BrowseDescriptionCollection
{
new()
{
NodeId = node,
BrowseDirection = BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences,
IncludeSubtypes = true,
NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable),
ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName
| BrowseResultMask.NodeClass | BrowseResultMask.TypeDefinition),
}
};
BrowseResponse resp;
try
{
resp = await session.BrowseAsync(
requestHeader: null,
view: null,
requestedMaxReferencesPerNode: 0,
nodesToBrowse: browseDescriptions,
ct: ct).ConfigureAwait(false);
}
catch
{
// Transient browse failure on a sub-tree — don't kill the whole discovery, just
// skip this branch. The driver's health surface will reflect the cascade via the
// probe loop (PR 69).
return;
}
if (resp.Results.Count == 0) return;
var refs = resp.Results[0].References;
foreach (var rf in refs)
{
if (discovered() >= _options.MaxDiscoveredNodes) break;
var childId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris);
if (NodeId.IsNull(childId)) continue;
var browseName = rf.BrowseName?.Name ?? childId.ToString();
var displayName = rf.DisplayName?.Text ?? browseName;
if (rf.NodeClass == NodeClass.Object)
{
var subFolder = folder.Folder(browseName, displayName);
increment();
await BrowseRecursiveAsync(session, childId, subFolder, visited,
depth + 1, discovered, increment, ct).ConfigureAwait(false);
}
else if (rf.NodeClass == NodeClass.Variable)
{
// Serialize the NodeId so the IReadable/IWritable surface receives a
// round-trippable string. Deferring the DataType + AccessLevel fetch to a
// follow-up PR — initial browse uses a conservative ViewOnly + Int32 default.
var nodeIdString = childId.ToString() ?? string.Empty;
folder.Variable(browseName, displayName, new DriverAttributeInfo(
FullName: nodeIdString,
DriverDataType: DriverDataType.Int32,
IsArray: false,
ArrayDim: null,
SecurityClass: SecurityClassification.ViewOnly,
IsHistorized: false,
IsAlarm: false));
increment();
}
}
}
// ---- ISubscribable ----
public async Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var session = RequireSession();
var id = Interlocked.Increment(ref _nextSubscriptionId);
var handle = new OpcUaSubscriptionHandle(id);
// Floor the publishing interval at 50ms — OPC UA servers routinely negotiate
// minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation
// bandwidth on every subscription create.
var intervalMs = publishingInterval < TimeSpan.FromMilliseconds(50)
? 50
: (int)publishingInterval.TotalMilliseconds;
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions
{
DisplayName = $"opcua-sub-{id}",
PublishingInterval = intervalMs,
KeepAliveCount = 10,
LifetimeCount = 1000,
MaxNotificationsPerPublish = 0,
PublishingEnabled = true,
Priority = 0,
TimestampsToReturn = TimestampsToReturn.Both,
});
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
session.AddSubscription(subscription);
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
foreach (var fullRef in fullReferences)
{
if (!TryParseNodeId(session, fullRef, out var nodeId)) continue;
// The tag string is routed through MonitoredItem.Handle so the Notification
// handler can identify which tag changed without an extra lookup.
var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions
{
DisplayName = fullRef,
StartNodeId = nodeId,
AttributeId = Attributes.Value,
MonitoringMode = MonitoringMode.Reporting,
SamplingInterval = intervalMs,
QueueSize = 1,
DiscardOldest = true,
})
{
Handle = fullRef,
};
item.Notification += (mi, args) => OnMonitoredItemNotification(handle, mi, args);
subscription.AddItem(item);
}
await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false);
_subscriptions[id] = new RemoteSubscription(subscription, handle);
}
finally { _gate.Release(); }
return handle;
}
public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
if (handle is not OpcUaSubscriptionHandle h) return;
if (!_subscriptions.TryRemove(h.Id, out var rs)) return;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort — the subscription may already be gone on reconnect */ }
}
finally { _gate.Release(); }
}
private void OnMonitoredItemNotification(OpcUaSubscriptionHandle handle, MonitoredItem item, MonitoredItemNotificationEventArgs args)
{
// args.NotificationValue arrives as a MonitoredItemNotification for value-change
// subscriptions; extract its DataValue. The Handle property carries our tag string.
if (args.NotificationValue is not MonitoredItemNotification mn) return;
var dv = mn.Value;
if (dv is null) return;
var fullRef = (item.Handle as string) ?? item.DisplayName ?? string.Empty;
var snapshot = new DataValueSnapshot(
Value: dv.Value,
StatusCode: dv.StatusCode.Code,
SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp,
ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? DateTime.UtcNow : dv.ServerTimestamp);
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot));
}
private sealed record RemoteSubscription(Subscription Subscription, OpcUaSubscriptionHandle Handle);
private sealed record OpcUaSubscriptionHandle(long Id) : ISubscriptionHandle
{
public string DiagnosticId => $"opcua-sub-{Id}";
}
// ---- IHostConnectivityProbe ----
/// <summary>Endpoint-URL-keyed host identity for the Admin /hosts dashboard.</summary>
public string HostName => _options.EndpointUrl;
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
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));
}
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
catch { /* disposal is best-effort */ }
_gate.Dispose();
}
}

View File

@@ -0,0 +1,140 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// OPC UA Client (gateway) driver configuration. Bound from <c>DriverConfig</c> JSON at
/// driver-host registration time. Models the settings documented in
/// <c>docs/v2/driver-specs.md</c> §8.
/// </summary>
/// <remarks>
/// This driver connects to a REMOTE OPC UA server and re-exposes its address space
/// through the local OtOpcUa server — the opposite direction from the usual "server
/// exposes PLC data" flow. Tier A (pure managed, OPC Foundation reference SDK); universal
/// protections cover it.
/// </remarks>
public sealed class OpcUaClientDriverOptions
{
/// <summary>Remote OPC UA endpoint URL, e.g. <c>opc.tcp://plc.internal:4840</c>.</summary>
public string EndpointUrl { get; init; } = "opc.tcp://localhost:4840";
/// <summary>
/// Security policy to require when selecting an endpoint. Either a
/// <see cref="OpcUaSecurityPolicy"/> enum constant or a free-form string (for
/// forward-compatibility with future OPC UA policies not yet in the enum).
/// Matched against <c>EndpointDescription.SecurityPolicyUri</c> suffix — the driver
/// connects to the first endpoint whose policy name matches AND whose mode matches
/// <see cref="SecurityMode"/>. When set to <see cref="OpcUaSecurityPolicy.None"/>
/// the driver picks any unsecured endpoint regardless of policy string.
/// </summary>
public OpcUaSecurityPolicy SecurityPolicy { get; init; } = OpcUaSecurityPolicy.None;
/// <summary>Security mode.</summary>
public OpcUaSecurityMode SecurityMode { get; init; } = OpcUaSecurityMode.None;
/// <summary>Authentication type.</summary>
public OpcUaAuthType AuthType { get; init; } = OpcUaAuthType.Anonymous;
/// <summary>User name (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
public string? Username { get; init; }
/// <summary>Password (required only for <see cref="OpcUaAuthType.Username"/>).</summary>
public string? Password { get; init; }
/// <summary>Server-negotiated session timeout. Default 120s per driver-specs.md §8.</summary>
public TimeSpan SessionTimeout { get; init; } = TimeSpan.FromSeconds(120);
/// <summary>Client-side keep-alive interval.</summary>
public TimeSpan KeepAliveInterval { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>Initial reconnect delay after a session drop.</summary>
public TimeSpan ReconnectPeriod { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>
/// When <c>true</c>, the driver accepts any self-signed / untrusted server certificate.
/// Dev-only — must be <c>false</c> in production so MITM attacks against the opc.tcp
/// channel fail closed.
/// </summary>
public bool AutoAcceptCertificates { get; init; } = false;
/// <summary>
/// Application URI the driver reports during session creation. Must match the
/// subject-alt-name on the client certificate if one is used, which is why it's a
/// config knob rather than hard-coded.
/// </summary>
public string ApplicationUri { get; init; } = "urn:localhost:OtOpcUa:GatewayClient";
/// <summary>
/// Friendly name sent to the remote server for diagnostics. Shows up in the remote
/// server's session-list so operators can identify which gateway instance is calling.
/// </summary>
public string SessionName { get; init; } = "OtOpcUa-Gateway";
/// <summary>Connect + per-operation timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(10);
/// <summary>
/// Root NodeId to mirror. Default <c>null</c> = <c>ObjectsFolder</c> (i=85). Set to
/// a scoped root to restrict the address space the driver exposes locally — useful
/// when the remote server has tens of thousands of nodes and only a subset is
/// needed downstream.
/// </summary>
public string? BrowseRoot { get; init; }
/// <summary>
/// Cap on total nodes discovered during <c>DiscoverAsync</c>. Default 10_000 —
/// bounds memory on runaway remote servers without being so low that normal
/// deployments hit it. When the cap is reached discovery stops and a warning is
/// written to the driver health surface; the partially-discovered tree is still
/// projected into the local address space.
/// </summary>
public int MaxDiscoveredNodes { get; init; } = 10_000;
/// <summary>
/// Max hierarchical depth of the browse. Default 10 — deep enough for realistic
/// OPC UA information models, shallow enough that cyclic graphs can't spin the
/// browse forever.
/// </summary>
public int MaxBrowseDepth { get; init; } = 10;
}
/// <summary>OPC UA message security mode.</summary>
public enum OpcUaSecurityMode
{
None,
Sign,
SignAndEncrypt,
}
/// <summary>
/// OPC UA security policies recognized by the driver. Maps to the standard
/// <c>http://opcfoundation.org/UA/SecurityPolicy#</c> URI suffixes the SDK uses for
/// endpoint matching.
/// </summary>
/// <remarks>
/// <see cref="Basic128Rsa15"/> and <see cref="Basic256"/> are <b>deprecated</b> per OPC UA
/// spec v1.04 — they remain in the enum only for brownfield interop with older servers.
/// Prefer <see cref="Basic256Sha256"/>, <see cref="Aes128_Sha256_RsaOaep"/>, or
/// <see cref="Aes256_Sha256_RsaPss"/> for new deployments.
/// </remarks>
public enum OpcUaSecurityPolicy
{
/// <summary>No security. Unsigned, unencrypted wire.</summary>
None,
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
Basic128Rsa15,
/// <summary>Deprecated (OPC UA 1.04). Retained for legacy server interop.</summary>
Basic256,
/// <summary>Recommended baseline for current deployments.</summary>
Basic256Sha256,
/// <summary>Current OPC UA policy; AES-128 + SHA-256 + RSA-OAEP.</summary>
Aes128_Sha256_RsaOaep,
/// <summary>Current OPC UA policy; AES-256 + SHA-256 + RSA-PSS.</summary>
Aes256_Sha256_RsaPss,
}
/// <summary>User authentication type sent to the remote server.</summary>
public enum OpcUaAuthType
{
Anonymous,
Username,
Certificate,
}

View File

@@ -0,0 +1,28 @@
<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.OpcUaClient</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" Version="1.5.378.106"/>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Configuration" Version="1.5.378.106"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,216 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Siemens S7 memory area. The driver's tag-address parser maps every S7 tag string into
/// exactly one of these + an offset. Values match the on-wire S7 area codes only
/// incidentally — S7.Net uses its own <c>DataType</c> enum (<c>DataBlock</c>, <c>Memory</c>,
/// <c>Input</c>, <c>Output</c>, <c>Timer</c>, <c>Counter</c>) so the adapter layer translates.
/// </summary>
public enum S7Area
{
DataBlock,
Memory, // M (Merker / marker byte)
Input, // I (process-image input)
Output, // Q (process-image output)
Timer,
Counter,
}
/// <summary>
/// Access width for a DB / M / I / Q address. Timers and counters are always 16-bit
/// opaque (not user-addressable via size suffixes).
/// </summary>
public enum S7Size
{
Bit, // X
Byte, // B
Word, // W — 16-bit
DWord, // D — 32-bit
}
/// <summary>
/// Parsed form of an S7 tag-address string. Produced by <see cref="S7AddressParser.Parse"/>.
/// </summary>
/// <param name="Area">Memory area (DB, M, I, Q, T, C).</param>
/// <param name="DbNumber">Data block number; only meaningful when <paramref name="Area"/> is <see cref="S7Area.DataBlock"/>.</param>
/// <param name="Size">Access width. Always <see cref="S7Size.Word"/> for Timer and Counter.</param>
/// <param name="ByteOffset">Byte offset into the area (for DB/M/I/Q) or the timer/counter number.</param>
/// <param name="BitOffset">Bit position 0-7 when <paramref name="Size"/> is <see cref="S7Size.Bit"/>; 0 otherwise.</param>
public readonly record struct S7ParsedAddress(
S7Area Area,
int DbNumber,
S7Size Size,
int ByteOffset,
int BitOffset);
/// <summary>
/// Parses Siemens S7 address strings into <see cref="S7ParsedAddress"/>. Accepts the
/// Siemens TIA-Portal / STEP 7 Classic syntax documented in <c>docs/v2/driver-specs.md</c> §5:
/// <list type="bullet">
/// <item><c>DB{n}.DB{X|B|W|D}{offset}[.bit]</c> — e.g. <c>DB1.DBX0.0</c>, <c>DB1.DBW0</c>, <c>DB1.DBD4</c></item>
/// <item><c>M{B|W|D}{offset}</c> or <c>M{offset}.{bit}</c> — e.g. <c>MB0</c>, <c>MW0</c>, <c>MD4</c>, <c>M0.0</c></item>
/// <item><c>I{B|W|D}{offset}</c> or <c>I{offset}.{bit}</c> — e.g. <c>IB0</c>, <c>IW0</c>, <c>ID0</c>, <c>I0.0</c></item>
/// <item><c>Q{B|W|D}{offset}</c> or <c>Q{offset}.{bit}</c> — e.g. <c>QB0</c>, <c>QW0</c>, <c>QD0</c>, <c>Q0.0</c></item>
/// <item><c>T{n}</c> — e.g. <c>T0</c>, <c>T15</c></item>
/// <item><c>C{n}</c> — e.g. <c>C0</c>, <c>C10</c></item>
/// </list>
/// Grammar is case-insensitive. Leading/trailing whitespace tolerated. Bit specifiers
/// must be 0-7; byte offsets must be non-negative; DB numbers must be &gt;= 1.
/// </summary>
/// <remarks>
/// Parse is deliberately strict — the parser rejects syntactic garbage up-front so a bad
/// tag config fails at driver init time instead of surfacing as a misleading
/// <c>BadInternalError</c> on every Read against that tag.
/// </remarks>
public static class S7AddressParser
{
/// <summary>
/// Parse an S7 address. Throws <see cref="FormatException"/> on any syntax error with
/// the offending input echoed in the message so operators can correlate to the tag
/// config that produced the fault.
/// </summary>
public static S7ParsedAddress Parse(string address)
{
if (string.IsNullOrWhiteSpace(address))
throw new FormatException("S7 address must not be empty");
var s = address.Trim().ToUpperInvariant();
// --- DB{n}.DB{X|B|W|D}{offset}[.bit] ---
if (s.StartsWith("DB") && TryParseDataBlock(s, out var dbResult))
return dbResult;
if (s.Length < 2)
throw new FormatException($"S7 address '{address}' is too short to parse");
var areaChar = s[0];
var rest = s.Substring(1);
switch (areaChar)
{
case 'M': return ParseMIQ(S7Area.Memory, rest, address);
case 'I': return ParseMIQ(S7Area.Input, rest, address);
case 'Q': return ParseMIQ(S7Area.Output, rest, address);
case 'T': return ParseTimerOrCounter(S7Area.Timer, rest, address);
case 'C': return ParseTimerOrCounter(S7Area.Counter, rest, address);
default:
throw new FormatException($"S7 address '{address}' starts with unknown area '{areaChar}' (expected DB/M/I/Q/T/C)");
}
}
/// <summary>
/// Try-parse variant for callers that can't afford an exception on bad input (e.g.
/// config validation pages in the Admin UI). Returns <c>false</c> for any input that
/// would throw from <see cref="Parse"/>.
/// </summary>
public static bool TryParse(string address, out S7ParsedAddress result)
{
try
{
result = Parse(address);
return true;
}
catch (FormatException)
{
result = default;
return false;
}
}
private static bool TryParseDataBlock(string s, out S7ParsedAddress result)
{
result = default;
// Split on first '.': left side must be DB{n}, right side DB{X|B|W|D}{offset}[.bit]
var dot = s.IndexOf('.');
if (dot < 0) return false;
var head = s.Substring(0, dot); // DB{n}
var tail = s.Substring(dot + 1); // DB{X|B|W|D}{offset}[.bit]
if (head.Length < 3) return false;
if (!int.TryParse(head.AsSpan(2), out var dbNumber) || dbNumber < 1)
throw new FormatException($"S7 DB number in '{s}' must be a positive integer");
if (!tail.StartsWith("DB") || tail.Length < 4)
throw new FormatException($"S7 DB address tail '{tail}' must start with DB{{X|B|W|D}}");
var sizeChar = tail[2];
var offsetStart = 3;
var size = sizeChar switch
{
'X' => S7Size.Bit,
'B' => S7Size.Byte,
'W' => S7Size.Word,
'D' => S7Size.DWord,
_ => throw new FormatException($"S7 DB size '{sizeChar}' in '{s}' must be X/B/W/D"),
};
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(tail, offsetStart, size, s);
result = new S7ParsedAddress(S7Area.DataBlock, dbNumber, size, byteOffset, bitOffset);
return true;
}
private static S7ParsedAddress ParseMIQ(S7Area area, string rest, string original)
{
if (rest.Length == 0)
throw new FormatException($"S7 address '{original}' has no offset");
var first = rest[0];
S7Size size;
int offsetStart;
switch (first)
{
case 'B': size = S7Size.Byte; offsetStart = 1; break;
case 'W': size = S7Size.Word; offsetStart = 1; break;
case 'D': size = S7Size.DWord; offsetStart = 1; break;
default:
// No size prefix => bit-level address requires explicit .bit. Size stays Bit;
// ParseOffsetAndOptionalBit will demand the dot.
size = S7Size.Bit;
offsetStart = 0;
break;
}
var (byteOffset, bitOffset) = ParseOffsetAndOptionalBit(rest, offsetStart, size, original);
return new S7ParsedAddress(area, DbNumber: 0, size, byteOffset, bitOffset);
}
private static S7ParsedAddress ParseTimerOrCounter(S7Area area, string rest, string original)
{
if (rest.Length == 0)
throw new FormatException($"S7 address '{original}' has no {area} number");
if (!int.TryParse(rest, out var number) || number < 0)
throw new FormatException($"S7 {area} number in '{original}' must be a non-negative integer");
return new S7ParsedAddress(area, DbNumber: 0, S7Size.Word, number, BitOffset: 0);
}
private static (int byteOffset, int bitOffset) ParseOffsetAndOptionalBit(
string s, int start, S7Size size, string original)
{
var offsetEnd = start;
while (offsetEnd < s.Length && s[offsetEnd] >= '0' && s[offsetEnd] <= '9')
offsetEnd++;
if (offsetEnd == start)
throw new FormatException($"S7 address '{original}' has no byte-offset digits");
if (!int.TryParse(s.AsSpan(start, offsetEnd - start), out var byteOffset) || byteOffset < 0)
throw new FormatException($"S7 byte offset in '{original}' must be non-negative");
// No bit-suffix: done unless size is Bit with no prefix, which requires one.
if (offsetEnd == s.Length)
{
if (size == S7Size.Bit)
throw new FormatException($"S7 address '{original}' needs a .{{bit}} suffix for bit access");
return (byteOffset, 0);
}
if (s[offsetEnd] != '.')
throw new FormatException($"S7 address '{original}' has unexpected character after offset");
if (size != S7Size.Bit)
throw new FormatException($"S7 address '{original}' has a bit suffix but the size is {size} — bit access needs X (DB) or no size prefix (M/I/Q)");
if (!int.TryParse(s.AsSpan(offsetEnd + 1), out var bitOffset) || bitOffset is < 0 or > 7)
throw new FormatException($"S7 bit offset in '{original}' must be 0-7");
return (byteOffset, bitOffset);
}
}

View File

@@ -0,0 +1,513 @@
using S7.Net;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Siemens S7 native driver — speaks S7comm over ISO-on-TCP (port 102) via the S7netplus
/// library. First implementation of <see cref="IDriver"/> for an in-process .NET Standard
/// PLC protocol that is NOT Modbus, validating that the v2 driver-capability interfaces
/// generalize beyond Modbus + Galaxy.
/// </summary>
/// <remarks>
/// <para>
/// PR 62 ships the scaffold: <see cref="IDriver"/> only (Initialize / Reinitialize /
/// Shutdown / GetHealth). <see cref="ITagDiscovery"/>, <see cref="IReadable"/>,
/// <see cref="IWritable"/>, <see cref="ISubscribable"/>, <see cref="IHostConnectivityProbe"/>
/// land in PRs 63-65 once the address parser (PR 63) is in place.
/// </para>
/// <para>
/// <b>Single-connection policy</b>: S7netplus documented pattern is one
/// <c>Plc</c> instance per PLC, serialized with a <see cref="SemaphoreSlim"/>.
/// Parallelising reads against a single S7 CPU doesn't help — the CPU scans the
/// communication mailbox at most once per cycle (2-10 ms) and queues concurrent
/// requests wire-side anyway. Multiple client-side connections just waste the CPU's
/// 8-64 connection-resource budget.
/// </para>
/// </remarks>
public sealed class S7Driver(S7DriverOptions options, string driverInstanceId)
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IDisposable, IAsyncDisposable
{
// ---- ISubscribable + IHostConnectivityProbe state ----
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
private long _nextSubscriptionId;
private readonly object _probeLock = new();
private HostState _hostState = HostState.Unknown;
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
private CancellationTokenSource? _probeCts;
public event EventHandler<DataChangeEventArgs>? OnDataChange;
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <summary>OPC UA StatusCode used when the tag name isn't in the driver's tag map.</summary>
private const uint StatusBadNodeIdUnknown = 0x80340000u;
/// <summary>OPC UA StatusCode used when the tag's data type isn't implemented yet.</summary>
private const uint StatusBadNotSupported = 0x803D0000u;
/// <summary>OPC UA StatusCode used when the tag is declared read-only.</summary>
private const uint StatusBadNotWritable = 0x803B0000u;
/// <summary>OPC UA StatusCode used when write fails validation (e.g. out-of-range value).</summary>
private const uint StatusBadInternalError = 0x80020000u;
/// <summary>OPC UA StatusCode used for socket / timeout / protocol-layer faults.</summary>
private const uint StatusBadCommunicationError = 0x80050000u;
/// <summary>OPC UA StatusCode used when S7 returns <c>ErrorCode.WrongCPU</c> / PUT/GET disabled.</summary>
private const uint StatusBadDeviceFailure = 0x80550000u;
private readonly Dictionary<string, S7TagDefinition> _tagsByName = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, S7ParsedAddress> _parsedByName = new(StringComparer.OrdinalIgnoreCase);
private readonly S7DriverOptions _options = options;
private readonly SemaphoreSlim _gate = new(1, 1);
/// <summary>
/// Per-connection gate. Internal so PRs 63-65 (read/write/subscribe) can serialize on
/// the same semaphore without exposing it publicly. Single-connection-per-PLC is a
/// hard requirement of S7netplus — see class remarks.
/// </summary>
internal SemaphoreSlim Gate => _gate;
/// <summary>
/// Active S7.Net PLC connection. Null until <see cref="InitializeAsync"/> returns; null
/// after <see cref="ShutdownAsync"/>. Read-only outside this class; PR 64's Read/Write
/// will take the <see cref="_gate"/> before touching it.
/// </summary>
internal Plc? Plc { get; private set; }
private DriverHealth _health = new(DriverState.Unknown, null, null);
private bool _disposed;
public string DriverInstanceId => driverInstanceId;
public string DriverType => "S7";
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
var plc = new Plc(_options.CpuType, _options.Host, _options.Rack, _options.Slot);
// S7netplus writes timeouts into the underlying TcpClient via Plc.WriteTimeout /
// Plc.ReadTimeout (milliseconds). Set before OpenAsync so the handshake itself
// honours the bound.
plc.WriteTimeout = (int)_options.Timeout.TotalMilliseconds;
plc.ReadTimeout = (int)_options.Timeout.TotalMilliseconds;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(_options.Timeout);
await plc.OpenAsync(cts.Token).ConfigureAwait(false);
Plc = plc;
// Parse every tag's address once at init so config typos fail fast here instead
// of surfacing as BadInternalError on every Read against the bad tag. The parser
// also rejects bit-offset > 7, DB 0, unknown area letters, etc.
_tagsByName.Clear();
_parsedByName.Clear();
foreach (var t in _options.Tags)
{
var parsed = S7AddressParser.Parse(t.Address); // throws FormatException
_tagsByName[t.Name] = t;
_parsedByName[t.Name] = parsed;
}
_health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
// Kick off the probe loop once the connection is up. Initial HostState stays
// Unknown until the first probe tick succeeds — avoids broadcasting a premature
// Running transition before any PDU round-trip has happened.
if (_options.Probe.Enabled)
{
_probeCts = new CancellationTokenSource();
_ = Task.Run(() => ProbeLoopAsync(_probeCts.Token), _probeCts.Token);
}
}
catch (Exception ex)
{
// Clean up a partially-constructed Plc so a retry from the caller doesn't leak
// the TcpClient. S7netplus's Close() is best-effort and idempotent.
try { Plc?.Close(); } catch { }
Plc = null;
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
}
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
public 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();
try { Plc?.Close(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
return Task.CompletedTask;
}
public DriverHealth GetHealth() => _health;
/// <summary>
/// Approximate memory footprint. The Plc instance + one 240-960 byte PDU buffer is
/// under 4 KB; return 0 because the <see cref="IDriver"/> contract asks for a
/// driver-attributable growth number and S7.Net doesn't expose one.
/// </summary>
public long GetMemoryFootprint() => 0;
public Task FlushOptionalCachesAsync(CancellationToken cancellationToken) => Task.CompletedTask;
// ---- IReadable ----
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var plc = RequirePlc();
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
for (var i = 0; i < fullReferences.Count; i++)
{
var name = fullReferences[i];
if (!_tagsByName.TryGetValue(name, out var tag))
{
results[i] = new DataValueSnapshot(null, StatusBadNodeIdUnknown, null, now);
continue;
}
try
{
var value = await ReadOneAsync(plc, tag, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, 0u, now, now);
_health = new DriverHealth(DriverState.Healthy, now, null);
}
catch (NotSupportedException)
{
results[i] = new DataValueSnapshot(null, StatusBadNotSupported, null, now);
}
catch (global::S7.Net.PlcException pex)
{
// S7.Net's PlcException carries an ErrorCode; PUT/GET-disabled on
// S7-1200/1500 surfaces here. Map to BadDeviceFailure so operators see a
// device-config problem (toggle PUT/GET in TIA Portal) rather than a
// transient fault — per driver-specs.md §5.
results[i] = new DataValueSnapshot(null, StatusBadDeviceFailure, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, pex.Message);
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
_health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
}
finally { _gate.Release(); }
return results;
}
private async Task<object> ReadOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, CancellationToken ct)
{
var addr = _parsedByName[tag.Name];
// S7.Net's string-based ReadAsync returns object where the boxed .NET type depends on
// the size suffix: DBX=bool, DBB=byte, DBW=ushort, DBD=uint. Our S7DataType enum
// specifies the SEMANTIC type (Int16 vs UInt16 vs Float32 etc.); the reinterpret below
// converts the raw unsigned boxed value into the requested type without issuing an
// extra PLC round-trip.
var raw = await plc.ReadAsync(tag.Address, ct).ConfigureAwait(false)
?? throw new System.IO.InvalidDataException($"S7.Net returned null for '{tag.Address}'");
return (tag.DataType, addr.Size, raw) switch
{
(S7DataType.Bool, S7Size.Bit, bool b) => b,
(S7DataType.Byte, S7Size.Byte, byte by) => by,
(S7DataType.UInt16, S7Size.Word, ushort u16) => u16,
(S7DataType.Int16, S7Size.Word, ushort u16) => unchecked((short)u16),
(S7DataType.UInt32, S7Size.DWord, uint u32) => u32,
(S7DataType.Int32, S7Size.DWord, uint u32) => unchecked((int)u32),
(S7DataType.Float32, S7Size.DWord, uint u32) => BitConverter.UInt32BitsToSingle(u32),
(S7DataType.Int64, _, _) => throw new NotSupportedException("S7 Int64 reads land in a follow-up PR"),
(S7DataType.UInt64, _, _) => throw new NotSupportedException("S7 UInt64 reads land in a follow-up PR"),
(S7DataType.Float64, _, _) => throw new NotSupportedException("S7 Float64 (LReal) reads land in a follow-up PR"),
(S7DataType.String, _, _) => throw new NotSupportedException("S7 STRING reads land in a follow-up PR"),
(S7DataType.DateTime, _, _) => throw new NotSupportedException("S7 DateTime reads land in a follow-up PR"),
_ => throw new System.IO.InvalidDataException(
$"S7 Read type-mismatch: tag '{tag.Name}' declared {tag.DataType} but address '{tag.Address}' " +
$"parsed as Size={addr.Size}; S7.Net returned {raw.GetType().Name}"),
};
}
// ---- IWritable ----
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
IReadOnlyList<WriteRequest> writes, CancellationToken cancellationToken)
{
var plc = RequirePlc();
var results = new WriteResult[writes.Count];
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
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)
{
results[i] = new WriteResult(StatusBadNotWritable);
continue;
}
try
{
await WriteOneAsync(plc, tag, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(0u);
}
catch (NotSupportedException)
{
results[i] = new WriteResult(StatusBadNotSupported);
}
catch (global::S7.Net.PlcException)
{
results[i] = new WriteResult(StatusBadDeviceFailure);
}
catch (Exception)
{
results[i] = new WriteResult(StatusBadInternalError);
}
}
}
finally { _gate.Release(); }
return results;
}
private async Task WriteOneAsync(global::S7.Net.Plc plc, S7TagDefinition tag, object? value, CancellationToken ct)
{
// S7.Net's Plc.WriteAsync(string address, object value) expects the boxed value to
// match the address's size-suffix type: DBX=bool, DBB=byte, DBW=ushort, DBD=uint.
// Our S7DataType lets the caller pass short/int/float; convert to the unsigned
// wire representation before handing off.
var boxed = tag.DataType switch
{
S7DataType.Bool => (object)Convert.ToBoolean(value),
S7DataType.Byte => (object)Convert.ToByte(value),
S7DataType.UInt16 => (object)Convert.ToUInt16(value),
S7DataType.Int16 => (object)unchecked((ushort)Convert.ToInt16(value)),
S7DataType.UInt32 => (object)Convert.ToUInt32(value),
S7DataType.Int32 => (object)unchecked((uint)Convert.ToInt32(value)),
S7DataType.Float32 => (object)BitConverter.SingleToUInt32Bits(Convert.ToSingle(value)),
S7DataType.Int64 => throw new NotSupportedException("S7 Int64 writes land in a follow-up PR"),
S7DataType.UInt64 => throw new NotSupportedException("S7 UInt64 writes land in a follow-up PR"),
S7DataType.Float64 => throw new NotSupportedException("S7 Float64 (LReal) writes land in a follow-up PR"),
S7DataType.String => throw new NotSupportedException("S7 STRING writes land in a follow-up PR"),
S7DataType.DateTime => throw new NotSupportedException("S7 DateTime writes land in a follow-up PR"),
_ => throw new InvalidOperationException($"Unknown S7DataType {tag.DataType}"),
};
await plc.WriteAsync(tag.Address, boxed, ct).ConfigureAwait(false);
}
private global::S7.Net.Plc RequirePlc() =>
Plc ?? throw new InvalidOperationException("S7Driver not initialized");
// ---- ITagDiscovery ----
public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
var folder = builder.Folder("S7", "S7");
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;
}
private static DriverDataType MapDataType(S7DataType t) => t switch
{
S7DataType.Bool => DriverDataType.Boolean,
S7DataType.Byte => DriverDataType.Int32, // no 8-bit in DriverDataType yet
S7DataType.Int16 or S7DataType.UInt16 or S7DataType.Int32 or S7DataType.UInt32 => DriverDataType.Int32,
S7DataType.Int64 or S7DataType.UInt64 => DriverDataType.Int32, // widens; lossy for >2^31-1
S7DataType.Float32 => DriverDataType.Float32,
S7DataType.Float64 => DriverDataType.Float64,
S7DataType.String => DriverDataType.String,
S7DataType.DateTime => DriverDataType.DateTime,
_ => DriverDataType.Int32,
};
// ---- ISubscribable (polling overlay) ----
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
var id = Interlocked.Increment(ref _nextSubscriptionId);
var cts = new CancellationTokenSource();
// Floor at 100 ms — S7 CPUs scan 2-10 ms but the comms mailbox is processed at most
// once per scan; sub-100 ms polling just queues wire-side with worse latency.
var interval = publishingInterval < TimeSpan.FromMilliseconds(100)
? TimeSpan.FromMilliseconds(100)
: publishingInterval;
var handle = new S7SubscriptionHandle(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 S7SubscriptionHandle 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 per OPC UA Part 4 convention.
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;
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(
S7SubscriptionHandle Handle,
IReadOnlyList<string> TagReferences,
TimeSpan Interval,
CancellationTokenSource Cts)
{
public System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
= new(StringComparer.OrdinalIgnoreCase);
}
private sealed record S7SubscriptionHandle(long Id) : ISubscriptionHandle
{
public string DiagnosticId => $"s7-sub-{Id}";
}
// ---- IHostConnectivityProbe ----
/// <summary>
/// Host identifier surfaced in <see cref="GetHostStatuses"/>. <c>host:port</c> format
/// matches the Modbus driver's convention so the Admin UI dashboard renders both
/// family's rows uniformly.
/// </summary>
public string HostName => $"{_options.Host}:{_options.Port}";
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
{
lock (_probeLock)
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
}
private async Task ProbeLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var success = false;
try
{
// Probe via S7.Net's low-cost GetCpuStatus — returns the CPU state (Run/Stop)
// and is intentionally light on the comms mailbox. Single-word Plc.ReadAsync
// would also work but GetCpuStatus doubles as a "PLC actually up" check.
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
probeCts.CancelAfter(_options.Probe.Timeout);
var plc = Plc;
if (plc is null) throw new InvalidOperationException("Plc dropped during probe");
await _gate.WaitAsync(probeCts.Token).ConfigureAwait(false);
try
{
_ = await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false);
success = true;
}
finally { _gate.Release(); }
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
catch { /* transport/timeout/exception — 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));
}
public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); }
catch { /* disposal is best-effort */ }
_gate.Dispose();
}
}

View File

@@ -0,0 +1,112 @@
using S7NetCpuType = global::S7.Net.CpuType;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// <summary>
/// Siemens S7 native (S7comm / ISO-on-TCP port 102) driver configuration. Bound from the
/// driver's <c>DriverConfig</c> JSON at <c>DriverHost.RegisterAsync</c>. Unlike the Modbus
/// driver the S7 driver uses the PLC's *native* protocol — port 102 ISO-on-TCP rather
/// than Modbus's 502, and S7-specific area codes (DB, M, I, Q) rather than holding-
/// register / coil tables.
/// </summary>
/// <remarks>
/// <para>
/// The driver requires <b>PUT/GET communication enabled</b> in the TIA Portal
/// hardware config for S7-1200/1500. The factory default disables PUT/GET access,
/// so a driver configured against a freshly-flashed CPU will see a hard error
/// (S7.Net surfaces it as <c>Plc.ReadAsync</c> returning <c>ErrorCode.Accessing</c>).
/// The driver maps that specifically to <c>BadNotSupported</c> and flags it as a
/// configuration alert rather than a transient fault — blind Polly retry is wasted
/// effort when the PLC will keep refusing every request.
/// </para>
/// <para>
/// See <c>docs/v2/driver-specs.md</c> §5 for the full specification.
/// </para>
/// </remarks>
public sealed class S7DriverOptions
{
/// <summary>PLC IP address or hostname.</summary>
public string Host { get; init; } = "127.0.0.1";
/// <summary>TCP port. ISO-on-TCP is 102 on every S7 model; override only for unusual NAT setups.</summary>
public int Port { get; init; } = 102;
/// <summary>
/// CPU family. Determines the ISO-TSAP slot byte that S7.Net uses during connection
/// setup — pick the family that matches the target PLC exactly.
/// </summary>
public S7NetCpuType CpuType { get; init; } = S7NetCpuType.S71500;
/// <summary>
/// Hardware rack number. Almost always 0; relevant only for distributed S7-400 racks
/// with multiple CPUs.
/// </summary>
public short Rack { get; init; } = 0;
/// <summary>
/// CPU slot. Conventions per family: S7-300 = slot 2, S7-400 = slot 2 or 3,
/// S7-1200 / S7-1500 = slot 0 (onboard PN). S7.Net uses this to build the remote
/// TSAP. Wrong slot → connection refused during handshake.
/// </summary>
public short Slot { get; init; } = 0;
/// <summary>Connect + per-operation timeout.</summary>
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5);
/// <summary>Pre-declared tag map. S7 has a symbol-table protocol but S7.Net does not expose it, so the driver operates off a static tag list configured per-site. Address grammar documented in S7AddressParser (PR 63).</summary>
public IReadOnlyList<S7TagDefinition> Tags { get; init; } = [];
/// <summary>
/// Background connectivity-probe settings. When enabled, the driver runs a tick loop
/// that issues a cheap read against <see cref="S7ProbeOptions.ProbeAddress"/> every
/// <see cref="S7ProbeOptions.Interval"/> and raises <c>OnHostStatusChanged</c> on
/// Running ↔ Stopped transitions.
/// </summary>
public S7ProbeOptions Probe { get; init; } = new();
}
public sealed class S7ProbeOptions
{
public bool Enabled { get; init; } = true;
public TimeSpan Interval { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
/// <summary>
/// Address to probe for liveness. DB1.DBW0 is the convention if the PLC project
/// reserves a small fingerprint DB for health checks (per <c>docs/v2/s7.md</c>);
/// if not, pick any valid Merker word like <c>MW0</c>.
/// </summary>
public string ProbeAddress { get; init; } = "MW0";
}
/// <summary>
/// One S7 variable as exposed by the driver. Addresses use S7.Net syntax — see
/// <c>S7AddressParser</c> (PR 63) for the grammar.
/// </summary>
/// <param name="Name">Tag name; OPC UA browse name + driver full reference.</param>
/// <param name="Address">S7 address string, e.g. <c>DB1.DBW0</c>, <c>M0.0</c>, <c>I0.0</c>, <c>QD4</c>. Grammar documented in <c>S7AddressParser</c> (PR 63).</param>
/// <param name="DataType">Logical data type — drives the underlying S7.Net read/write width.</param>
/// <param name="Writable">When true the driver accepts writes for this tag.</param>
/// <param name="StringLength">For <c>DataType = String</c>: S7-string max length. Default 254 (S7 max).</param>
public sealed record S7TagDefinition(
string Name,
string Address,
S7DataType DataType,
bool Writable = true,
int StringLength = 254);
public enum S7DataType
{
Bool,
Byte,
Int16,
UInt16,
Int32,
UInt32,
Int64,
UInt64,
Float32,
Float64,
String,
DateTime,
}

View File

@@ -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.S7</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.S7</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="S7netplus" Version="0.20.0"/>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.S7.Tests"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,109 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies DL260 I/O-memory coil mappings against the <c>dl205.json</c> pymodbus profile.
/// DirectLOGIC Y-outputs and C-relays are exposed to Modbus as FC01/FC05 coils, but at
/// non-zero base addresses that confuse operators used to "Y0 is the first coil". The sim
/// seeds Y0 → coil 2048 = ON and C0 → coil 3072 = ON as fixed markers.
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205CoilMappingTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL260_Y0_maps_to_coil_2048()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
}
var coil = DirectLogicAddress.YOutputToCoil("Y0");
coil.ShouldBe((ushort)2048);
var options = BuildOptions(sim, [
new ModbusTagDefinition("DL260_Y0",
ModbusRegion.Coils, Address: coil,
DataType: ModbusDataType.Bool, Writable: false),
]);
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-y0");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["DL260_Y0"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(true, "dl205.json seeds coil 2048 (Y0) = ON");
}
[Fact]
public async Task DL260_C0_maps_to_coil_3072()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
}
var coil = DirectLogicAddress.CRelayToCoil("C0");
coil.ShouldBe((ushort)3072);
var options = BuildOptions(sim, [
new ModbusTagDefinition("DL260_C0",
ModbusRegion.Coils, Address: coil,
DataType: ModbusDataType.Bool, Writable: false),
]);
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-c0");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["DL260_C0"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(true, "dl205.json seeds coil 3072 (C0) = ON");
}
[Fact]
public async Task DL260_scratch_Crelay_supports_write_then_read()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
}
// Scratch C-relay at coil 4000 (per dl205.json _quirk note) is writable. Write=true then
// read back to confirm FC05 round-trip works against the DL-mapped coil bank.
var options = BuildOptions(sim, [
new ModbusTagDefinition("DL260_C_Scratch",
ModbusRegion.Coils, Address: 4000,
DataType: ModbusDataType.Bool, Writable: true),
]);
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-cscratch");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var writeResults = await driver.WriteAsync(
[new(FullReference: "DL260_C_Scratch", Value: true)],
TestContext.Current.CancellationToken);
writeResults[0].StatusCode.ShouldBe(0u);
var readResults = await driver.ReadAsync(["DL260_C_Scratch"], TestContext.Current.CancellationToken);
readResults[0].StatusCode.ShouldBe(0u);
readResults[0].Value.ShouldBe(true);
}
private static ModbusDriverOptions BuildOptions(ModbusSimulatorFixture sim, IReadOnlyList<ModbusTagDefinition> tags)
=> new()
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags = tags,
Probe = new ModbusProbeOptions { Enabled = false },
};
}

View File

@@ -0,0 +1,53 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies the driver's Modbus-exception → OPC UA StatusCode translation end-to-end
/// against the dl205.json pymodbus profile. pymodbus returns exception 02 (Illegal Data
/// Address) for reads outside the configured register ranges, matching real DL205/DL260
/// firmware behavior per <c>docs/v2/dl205.md</c> §exception-codes. The driver must surface
/// that as <c>BadOutOfRange</c> (0x803C0000) — not <c>BadInternalError</c> — so the
/// operator sees a tag-config diagnosis instead of a generic driver-fault message.
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205ExceptionCodeTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL205_FC03_at_unmapped_register_returns_BadOutOfRange()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
}
// Address 16383 is the last cell of hr-size=16384 in dl205.json; address 16384 is
// beyond the configured HR range. pymodbus validates and returns exception 02
// (Illegal Data Address).
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("Unmapped",
ModbusRegion.HoldingRegisters, Address: 16383,
DataType: ModbusDataType.UInt16, Writable: false),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-exc");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["Unmapped"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0x803C0000u,
"DL205 returns exception 02 for an FC03 at an unmapped register; driver must translate to BadOutOfRange (not BadInternalError)");
}
}

View File

@@ -0,0 +1,71 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.DL205;
/// <summary>
/// Verifies the DL260 X-input discrete-input mapping against the <c>dl205.json</c>
/// pymodbus profile. X-inputs are FC02 discrete-input-only (Modbus doesn't allow writes
/// to discrete inputs), and the DirectLOGIC convention is X0 → DI 0 with octal offsets
/// for subsequent addresses. The sim seeds X20 octal (= DI 16) = ON so the test can
/// prove the helper routes through to the right cell.
/// </summary>
/// <remarks>
/// X0 / X1 / …X17 octal all share cell 0 (DI 0-15 → cell 0 bits 0-15) which conflicts
/// with the V0 uint16 marker; we can't seed both types at cell 0 under shared-blocks
/// semantics. So the test uses X20 octal (first address beyond the cell-0 boundary) which
/// lands cleanly at cell 1 bit 0 and leaves the V0 register-zero quirk intact.
/// </remarks>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "DL205")]
public sealed class DL205XInputTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task DL260_X20_octal_maps_to_DiscreteInput_16_and_reads_ON()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "dl205",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != dl205 — skipping.");
}
// X20 octal = decimal 16 = DI 16 per the DL260 convention (X-inputs start at DI 0).
var di = DirectLogicAddress.XInputToDiscrete("X20");
di.ShouldBe((ushort)16);
var options = BuildOptions(sim, [
new ModbusTagDefinition("DL260_X20",
ModbusRegion.DiscreteInputs, Address: di,
DataType: ModbusDataType.Bool, Writable: false),
// Unpopulated-X control: pymodbus returns 0 (not exception) for any bit in the
// configured DI range that wasn't explicitly seeded — per docs/v2/dl205.md
// "Reading a non-populated X input ... returns zero, not an exception".
new ModbusTagDefinition("DL260_X21_off",
ModbusRegion.DiscreteInputs, Address: DirectLogicAddress.XInputToDiscrete("X21"),
DataType: ModbusDataType.Bool, Writable: false),
]);
await using var driver = new ModbusDriver(options, driverInstanceId: "dl205-xinput");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["DL260_X20", "DL260_X21_off"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(true, "dl205.json seeds cell 1 bit 0 (X20 octal = DI 16) = ON");
results[1].StatusCode.ShouldBe(0u, "unpopulated X inputs must read cleanly — DL260 does NOT raise an exception");
results[1].Value.ShouldBe(false);
}
private static ModbusDriverOptions BuildOptions(ModbusSimulatorFixture sim, IReadOnlyList<ModbusTagDefinition> tags)
=> new()
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags = tags,
Probe = new ModbusProbeOptions { Enabled = false },
};
}

View File

@@ -0,0 +1,43 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi;
/// <summary>
/// Tag map for the Mitsubishi MELSEC device class with a representative Modbus Device
/// Assignment block mapping D0..D1023 → HR[0..1023]. Mirrors the behaviors in
/// <c>mitsubishi.json</c> pymodbus profile and <c>docs/v2/mitsubishi.md</c>.
/// </summary>
/// <remarks>
/// MELSEC Modbus sites all have *different* device-assignment parameter blocks; this profile
/// models the conventional default. Per-model differences (FX5U needs firmware ≥ 1.060 for
/// Modbus server; QJ71MT91 lacks FC22/FC23; FX/iQ-F use octal X/Y while Q/L/iQ-R use hex)
/// are handled in <see cref="MelsecAddress"/> (PR 59) and the per-model test files.
/// </remarks>
public static class MitsubishiProfile
{
/// <summary>
/// Scratch HR the smoke test writes + reads. Address 200 mirrors the
/// dl205/s7_1500/standard scratch range so one smoke test pattern works across every
/// device profile the simulator supports.
/// </summary>
public const ushort SmokeHoldingRegister = 200;
/// <summary>Value the smoke test writes then reads back.</summary>
public const short SmokeHoldingValue = 7890;
public static ModbusDriverOptions BuildOptions(string host, int port) => new()
{
Host = host,
Port = port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition(
Name: "Smoke_HReg200",
Region: ModbusRegion.HoldingRegisters,
Address: SmokeHoldingRegister,
DataType: ModbusDataType.Int16,
Writable: true),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
}

View File

@@ -0,0 +1,179 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi;
/// <summary>
/// Verifies the MELSEC-family Modbus quirks against the <c>mitsubishi.json</c> pymodbus
/// profile: CDAB word order default, binary-not-BCD D-register encoding, hex X-input
/// parsing (Q/L/iQ-R), D0 fingerprint, M-relay coil mapping with bank base.
/// </summary>
/// <remarks>
/// Groups all quirks in one test class instead of per-behavior classes (unlike the DL205
/// set) because MELSEC's per-model differentiation is handled by the
/// <see cref="MelsecFamily"/> enum on the helper + <c>MODBUS_SIM_PROFILE</c> env var on
/// the fixture, rather than per-PR test classes.
/// </remarks>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "Mitsubishi")]
public sealed class MitsubishiQuirkTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task Mitsubishi_D0_fingerprint_reads_0x1234()
{
if (!ShouldRun()) return;
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("D0_Fingerprint",
ModbusRegion.HoldingRegisters,
Address: MelsecAddress.DRegisterToHolding("D0"),
DataType: ModbusDataType.UInt16, Writable: false));
var r = await driver.ReadAsync(["D0_Fingerprint"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe((ushort)0x1234);
}
[Fact]
public async Task Mitsubishi_Float32_CDAB_decodes_1_5f_from_D100()
{
if (!ShouldRun()) return;
// MELSEC Q/L/iQ-R/iQ-F all store 32-bit values with CDAB word order (low word at
// lower D-register address). HR[100..101] = [0, 0x3FC0] decodes as 1.5f under
// WordSwap but as a denormal under BigEndian.
var addr = MelsecAddress.DRegisterToHolding("D100");
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("D100_Float_CDAB",
ModbusRegion.HoldingRegisters, Address: addr,
DataType: ModbusDataType.Float32, Writable: false,
ByteOrder: ModbusByteOrder.WordSwap),
new ModbusTagDefinition("D100_Float_ABCD_control",
ModbusRegion.HoldingRegisters, Address: addr,
DataType: ModbusDataType.Float32, Writable: false,
ByteOrder: ModbusByteOrder.BigEndian));
var r = await driver.ReadAsync(
["D100_Float_CDAB", "D100_Float_ABCD_control"],
TestContext.Current.CancellationToken);
r[0].Value.ShouldBe(1.5f, "MELSEC stores Float32 CDAB; WordSwap decode returns 1.5f");
r[1].Value.ShouldNotBe(1.5f, "same wire with BigEndian must decode to a different value");
}
[Fact]
public async Task Mitsubishi_D10_is_binary_not_BCD()
{
if (!ShouldRun()) return;
// Counter-to-DL205: MELSEC D-registers are binary by default. D10 = 1234 decimal =
// 0x04D2. Reading as Int16 returns 1234; reading as Bcd16 would throw (nibble 0xD is
// non-BCD) — the integration test proves the Int16 decode wins.
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("D10_Binary",
ModbusRegion.HoldingRegisters,
Address: MelsecAddress.DRegisterToHolding("D10"),
DataType: ModbusDataType.Int16, Writable: false));
var r = await driver.ReadAsync(["D10_Binary"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe((short)1234, "MELSEC stores numeric D-register values in binary; 0x04D2 = 1234");
}
[Fact]
public async Task Mitsubishi_D10_as_BCD_throws_because_nibble_is_non_decimal()
{
if (!ShouldRun()) return;
// If a site configured D10 with Bcd16 data type but the ladder writes binary, the
// BCD decoder MUST reject the garbage rather than silently returning wrong decimal.
// 0x04D2 contains nibble 0xD which fails BCD validation.
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("D10_WrongBcd",
ModbusRegion.HoldingRegisters,
Address: MelsecAddress.DRegisterToHolding("D10"),
DataType: ModbusDataType.Bcd16, Writable: false));
var r = await driver.ReadAsync(["D10_WrongBcd"], TestContext.Current.CancellationToken);
// ReadAsync catches the InvalidDataException from DecodeBcd and surfaces it as
// BadCommunicationError (PR 52 mapping). Non-zero status = caller sees a real
// problem and can check their tag config instead of getting silently-wrong numbers.
r[0].StatusCode.ShouldNotBe(0u, "BCD decode of binary 0x04D2 must fail loudly because nibble D is non-BCD");
}
[Fact]
public async Task Mitsubishi_QLiQR_X210_hex_maps_to_DI_528_reads_ON()
{
if (!ShouldRun()) return;
// MELSEC-Q / L / iQ-R: X addresses are hex. X210 = 0x210 = 528 decimal.
// mitsubishi.json seeds cell 33 (DI 528..543) with value 9 = bit 0 + bit 3 set.
// X210 → DI 528 → cell 33 bit 0 = 1 (ON).
var addr = MelsecAddress.XInputToDiscrete("X210", MelsecFamily.Q_L_iQR);
addr.ShouldBe((ushort)528);
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("X210_hex",
ModbusRegion.DiscreteInputs, Address: addr,
DataType: ModbusDataType.Bool, Writable: false));
var r = await driver.ReadAsync(["X210_hex"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe(true);
}
[Fact]
public void Mitsubishi_family_trap_X20_differs_on_Q_vs_FX()
{
// Not a live-sim test — a unit-level proof that the MELSEC family selector gates the
// address correctly. Included in the integration suite so anyone running the MELSEC
// tests sees the trap called out explicitly.
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.Q_L_iQR).ShouldBe((ushort)32);
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.F_iQF).ShouldBe((ushort)16);
}
[Fact]
public async Task Mitsubishi_M512_maps_to_coil_512_reads_ON()
{
if (!ShouldRun()) return;
// mitsubishi.json seeds cell 32 (coil 512..527) with value 5 = bit 0 + bit 2 set.
// M512 → coil 512 → cell 32 bit 0 = 1 (ON).
var addr = MelsecAddress.MRelayToCoil("M512");
addr.ShouldBe((ushort)512);
await using var driver = await NewDriverAsync(
new ModbusTagDefinition("M512",
ModbusRegion.Coils, Address: addr,
DataType: ModbusDataType.Bool, Writable: false));
var r = await driver.ReadAsync(["M512"], TestContext.Current.CancellationToken);
r[0].StatusCode.ShouldBe(0u);
r[0].Value.ShouldBe(true);
}
// --- helpers ---
private bool ShouldRun()
{
if (sim.SkipReason is not null) { Assert.Skip(sim.SkipReason); return false; }
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "mitsubishi",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != mitsubishi — skipping.");
return false;
}
return true;
}
private async Task<ModbusDriver> NewDriverAsync(params ModbusTagDefinition[] tags)
{
var drv = new ModbusDriver(
new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags = tags,
Probe = new ModbusProbeOptions { Enabled = false },
},
driverInstanceId: "melsec-quirk");
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
return drv;
}
}

View File

@@ -0,0 +1,45 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.Mitsubishi;
/// <summary>
/// End-to-end smoke against the MELSEC <c>mitsubishi.json</c> pymodbus profile (or a real
/// MELSEC QJ71MT91 / iQ-R / FX5U when <c>MODBUS_SIM_ENDPOINT</c> points at one). Drives
/// the full <see cref="ModbusDriver"/> + real <see cref="ModbusTcpTransport"/> stack.
/// Success proves the driver initializes against the MELSEC sim, writes a known value,
/// and reads it back — the baseline every Mitsubishi-specific test (PR 59+) builds on.
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "Mitsubishi")]
public sealed class MitsubishiSmokeTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task Mitsubishi_roundtrip_write_then_read_of_holding_register()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "mitsubishi",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != mitsubishi — skipping.");
}
var options = MitsubishiProfile.BuildOptions(sim.Host, sim.Port);
await using var driver = new ModbusDriver(options, driverInstanceId: "melsec-smoke");
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
var writeResults = await driver.WriteAsync(
[new(FullReference: "Smoke_HReg200", Value: (short)MitsubishiProfile.SmokeHoldingValue)],
TestContext.Current.CancellationToken);
writeResults.Count.ShouldBe(1);
writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the MELSEC pymodbus profile");
var readResults = await driver.ReadAsync(
["Smoke_HReg200"],
TestContext.Current.CancellationToken);
readResults.Count.ShouldBe(1);
readResults[0].StatusCode.ShouldBe(0u);
readResults[0].Value.ShouldBe((short)MitsubishiProfile.SmokeHoldingValue);
}
}

View File

@@ -36,9 +36,10 @@
[1280, 1282],
[1343, 1343],
[1407, 1407],
[2048, 2050],
[3072, 3074],
[4000, 4007],
[1, 1],
[128, 128],
[192, 192],
[250, 250],
[8448, 8448]
],
@@ -88,25 +89,17 @@
],
"bits": [
{"_quirk": "Y0 marker. DL260 maps Y0 to coil 2048 (0-based). Coil 2048 = ON proves the mapping.",
"addr": 2048, "value": 1},
{"addr": 2049, "value": 0},
{"addr": 2050, "value": 1},
{"_quirk": "X-input bank marker cell. X0 -> DI 0 conflicts with uint16 V0 at cell 0, so this marker covers X20 octal (= decimal 16 = DI 16 = cell 1 bit 0). X20=ON, X23 octal (DI 19 = cell 1 bit 3)=ON -> cell 1 value = 0b00001001 = 9.",
"addr": 1, "value": 9},
{"_quirk": "C0 marker. DL260 maps C0 to coil 3072 (0-based). Coil 3072 = ON proves the mapping.",
"addr": 3072, "value": 1},
{"addr": 3073, "value": 0},
{"addr": 3074, "value": 1},
{"_quirk": "Y-output bank marker cell. pymodbus's simulator maps Modbus FC01/02/05 bit-addresses to cell index = bit_addr / 16; so Modbus coil 2048 lives at cell 128 bit 0. Y0=ON (bit 0), Y1=OFF (bit 1), Y2=ON (bit 2) -> value=0b00000101=5 proves DL260 mapping Y0 -> coil 2048.",
"addr": 128, "value": 5},
{"_quirk": "Scratch C-relays for write-roundtrip tests against the writable C range.",
"addr": 4000, "value": 0},
{"addr": 4001, "value": 0},
{"addr": 4002, "value": 0},
{"addr": 4003, "value": 0},
{"addr": 4004, "value": 0},
{"addr": 4005, "value": 0},
{"addr": 4006, "value": 0},
{"addr": 4007, "value": 0}
{"_quirk": "C-relay bank marker cell. Modbus coil 3072 -> cell 192 bit 0. C0=ON (bit 0), C1=OFF (bit 1), C2=ON (bit 2) -> value=5 proves DL260 mapping C0 -> coil 3072.",
"addr": 192, "value": 5},
{"_quirk": "Scratch cell for coil 4000..4015 write round-trip tests. Cell 250 holds Modbus coils 4000-4015; all bits start at 0 and tests set specific bits via FC05.",
"addr": 250, "value": 0}
],
"uint32": [],

View File

@@ -0,0 +1,83 @@
{
"_comment": "mitsubishi.json -- Mitsubishi MELSEC Modbus TCP quirk simulator covering QJ71MT91, iQ-R, iQ-F/FX5U, and FX3U-ENET-P502 behaviors documented in docs/v2/mitsubishi.md. MELSEC CPUs store multi-word values in CDAB order (opposite of S7 ABCD, same family as DL260). The Modbus-module 'Modbus Device Assignment Parameter' block is per-site, so this profile models one *representative* assignment mapping D-register D0..D1023 -> HR 0..1023, M-relay M0..M511 -> coil 0..511, X-input X0..X15 -> DI 0..15 (X-addresses are HEX on Q/L/iQ-R, so X10 = decimal 16; on FX/iQ-F they're OCTAL like DL260). pymodbus bit-address semantics are the same as dl205.json and s7_1500.json (FC01/02/05/15 address N maps to cell index N/16).",
"server_list": {
"srv": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5020,
"framer": "socket",
"device_id": 1
}
},
"device_list": {
"dev": {
"setup": {
"co size": 4096,
"di size": 4096,
"hr size": 4096,
"ir size": 1024,
"shared blocks": true,
"type exception": false,
"defaults": {
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
}
},
"invalid": [],
"write": [
[0, 0],
[10, 10],
[100, 101],
[200, 209],
[300, 301],
[500, 500]
],
"uint16": [
{"_quirk": "D0 fingerprint marker. MELSEC D0 is the first data register; Modbus Device Assignment typically maps D0..D1023 -> HR 0..1023. 0x1234 is the fingerprint operators set in GX Works to prove the mapping parameter block is in effect.",
"addr": 0, "value": 4660},
{"_quirk": "Scratch HR range 200..209 -- mirrors the dl205/s7_1500/standard scratch range so smoke tests (MitsubishiProfile.SmokeHoldingRegister=200) round-trip identically against any profile.",
"addr": 200, "value": 0},
{"addr": 201, "value": 0},
{"addr": 202, "value": 0},
{"addr": 203, "value": 0},
{"addr": 204, "value": 0},
{"addr": 205, "value": 0},
{"addr": 206, "value": 0},
{"addr": 207, "value": 0},
{"addr": 208, "value": 0},
{"addr": 209, "value": 0},
{"_quirk": "Float32 1.5f in CDAB word order (MELSEC Q/L/iQ-R/iQ-F default, same as DL260). HR[100]=0x0000=0 low word, HR[101]=0x3FC0=16320 high word. Decode with ByteOrder.WordSwap returns 1.5f; BigEndian decode returns a denormal.",
"addr": 100, "value": 0},
{"addr": 101, "value": 16320},
{"_quirk": "Int32 0x12345678 in CDAB word order. HR[300]=0x5678=22136 low word, HR[301]=0x1234=4660 high word. Contrasts with the S7 profile's ABCD encoding at the same address.",
"addr": 300, "value": 22136},
{"addr": 301, "value": 4660},
{"_quirk": "D10 = decimal 1234 stored as BINARY (NOT BCD like DL205). 0x04D2 = 1234 decimal. Caller reading with Bcd16 data type would decode this as binary 1234's BCD nibbles which are non-BCD and throw InvalidDataException -- proves MELSEC is binary-by-default, opposite of DL205's BCD-by-default quirk.",
"addr": 10, "value": 1234},
{"_quirk": "Modbus Device Assignment boundary marker. HR[500] represents the last register in an assigned D-range D500. Beyond this (HR[501..4095]) would be Illegal Data Address on a real QJ71MT91 with this specific parameter block; pymodbus returns default 0 because its shared cell array has space -- real-PLC parity is documented in docs/v2/mitsubishi.md §device-assignment, not enforced here.",
"addr": 500, "value": 500}
],
"bits": [
{"_quirk": "M-relay marker cell at cell 32 = Modbus coil 512 = MELSEC M512 (coils 0..15 collide with the D0 uint16 marker cell, so we place the M marker above that). Cell 32 bit 0 = 1 and bit 2 = 1 (value = 0b101 = 5) = M512=ON, M513=OFF, M514=ON. Matches the Y0/Y2 marker pattern in dl205 and s7_1500 profiles.",
"addr": 32, "value": 5},
{"_quirk": "X-input marker cell at cell 33 = Modbus DI 528 (= MELSEC X210 hex on Q/L/iQ-R). Cell 33 bit 0 = 1 and bit 3 = 1 (value = 0x9 = 9). Chosen above cell 1 so it doesn't collide with any uint16 D-register. Proves the hex-parsing X-input helper on Q/L/iQ-R family; FX/iQ-F families use octal X-addresses tested separately.",
"addr": 33, "value": 9}
],
"uint32": [],
"float32": [],
"string": [],
"repeat": []
}
}
}

View File

@@ -0,0 +1,77 @@
{
"_comment": "s7_1500.json -- Siemens SIMATIC S7-1500 + MB_SERVER quirk simulator. Models docs/v2/s7.md behaviors as concrete register values. Unlike DL260 (CDAB word order default) or Mitsubishi (CDAB default), S7 MB_SERVER uses ABCD word order by default because Siemens native CPU types are big-endian top-to-bottom both within the register pair and byte pair. This profile exists so the driver's S7 profile default ByteOrder.BigEndian can be validated end-to-end. pymodbus bit-address semantics are the same as dl205.json (FC01/02/05/15 address X maps to cell index X/16); seed bits at the appropriate cell-indexed positions.",
"server_list": {
"srv": {
"comm": "tcp",
"host": "0.0.0.0",
"port": 5020,
"framer": "socket",
"device_id": 1
}
},
"device_list": {
"dev": {
"setup": {
"co size": 4096,
"di size": 4096,
"hr size": 4096,
"ir size": 1024,
"shared blocks": true,
"type exception": false,
"defaults": {
"value": {"bits": 0, "uint16": 0, "uint32": 0, "float32": 0.0, "string": " "},
"action": {"bits": null, "uint16": null, "uint32": null, "float32": null, "string": null}
}
},
"invalid": [],
"write": [
[0, 0],
[25, 25],
[100, 101],
[200, 209],
[300, 301]
],
"uint16": [
{"_quirk": "DB1 header marker. On an S7-1500 with MB_SERVER pointing at DB1, operators often reserve DB1.DBW0 for a fingerprint word so clients can verify they're talking to the right DB. 0xABCD = 43981.",
"addr": 0, "value": 43981},
{"_quirk": "Scratch HR range 200..209 -- mirrors the standard.json scratch range so the smoke test (S7_1500Profile.SmokeHoldingRegister=200) round-trips identically against either profile.",
"addr": 200, "value": 0},
{"addr": 201, "value": 0},
{"addr": 202, "value": 0},
{"addr": 203, "value": 0},
{"addr": 204, "value": 0},
{"addr": 205, "value": 0},
{"addr": 206, "value": 0},
{"addr": 207, "value": 0},
{"addr": 208, "value": 0},
{"addr": 209, "value": 0},
{"_quirk": "Float32 1.5f in ABCD word order (Siemens big-endian default, OPPOSITE of DL260 CDAB). IEEE-754 1.5 = 0x3FC00000. ABCD = high word first: HR[100]=0x3FC0=16320, HR[101]=0x0000=0.",
"addr": 100, "value": 16320},
{"_quirk": "Float32 1.5f ABCD low word.",
"addr": 101, "value": 0},
{"_quirk": "Int32 0x12345678 in ABCD word order. HR[300]=0x1234=4660, HR[301]=0x5678=22136. Demonstrates the contrast with DL260 CDAB Int32 encoding.",
"addr": 300, "value": 4660},
{"addr": 301, "value": 22136}
],
"bits": [
{"_quirk": "Coil bank marker cell. S7 MB_SERVER doesn't fix coil addresses; this simulates a user-wired DB where coil 400 (=bit 0 of cell 25) represents a latched digital output. Cell 25 bit 0 = 1 proves the wire-format round-trip works for coils on S7 profile.",
"addr": 25, "value": 1},
{"_quirk": "Discrete-input bank marker cell. DI 500 (=bit 0 of cell 31) = 1. Like coils, discrete inputs on S7 MB_SERVER are per-site; we assert the end-to-end FC02 path only.",
"addr": 31, "value": 1}
],
"uint32": [],
"float32": [],
"string": [],
"repeat": []
}
}
}

View File

@@ -21,7 +21,7 @@
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [ValidateSet('standard', 'dl205')] [string]$Profile,
[Parameter(Mandatory)] [ValidateSet('standard', 'dl205', 's7_1500', 'mitsubishi')] [string]$Profile,
[int]$HttpPort = 8080
)

View File

@@ -0,0 +1,44 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
/// <summary>
/// Tag map for the Siemens SIMATIC S7-1500 device class with the <c>MB_SERVER</c> library
/// block mapping HR[0..] to DB1.DBW0+. Mirrors <c>s7_1500.json</c> in <c>Pymodbus/</c>.
/// </summary>
/// <remarks>
/// Unlike DL205, S7 has no fixed Modbus memory map — every site wires MB_SERVER to a
/// different DB. The profile here models the *default* user layout documented in
/// <c>docs/v2/s7.md</c> §per-model-matrix: DB1.DBW0 = fingerprint marker, a scratch HR
/// range 200..209 for write-roundtrip tests, and ABCD-order Float32 / Int32 markers at
/// HR[100..101] and HR[300..301] to prove the driver's S7 profile default is correct.
/// </remarks>
public static class S7_1500Profile
{
/// <summary>
/// Scratch HR the smoke test writes + reads. Address 200 mirrors the DL205 /
/// standard scratch range so one smoke test pattern works across all device profiles.
/// </summary>
public const ushort SmokeHoldingRegister = 200;
/// <summary>Value the smoke test writes then reads back.</summary>
public const short SmokeHoldingValue = 4321;
public static ModbusDriverOptions BuildOptions(string host, int port) => new()
{
Host = host,
Port = port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition(
Name: "Smoke_HReg200",
Region: ModbusRegion.HoldingRegisters,
Address: SmokeHoldingRegister,
DataType: ModbusDataType.Int16,
Writable: true),
],
// Disable the background probe loop — integration tests drive reads explicitly and
// the probe would race with assertions.
Probe = new ModbusProbeOptions { Enabled = false },
};
}

View File

@@ -0,0 +1,54 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
/// <summary>
/// End-to-end smoke against the S7-1500 <c>MB_SERVER</c> pymodbus profile (or a real
/// S7-1500 + MB_SERVER deployment when <c>MODBUS_SIM_ENDPOINT</c> points at one). Drives
/// the full <see cref="ModbusDriver"/> + real <see cref="ModbusTcpTransport"/> stack —
/// no fake transport. Success proves the driver initializes against the S7 simulator,
/// writes a known value, and reads it back with the correct status and value, which is
/// the baseline every S7-specific test (PR 57+) builds on.
/// </summary>
/// <remarks>
/// S7-specific quirk tests (MB_SERVER requires non-optimized DBs, ABCD word order
/// default, port-per-connection, FC23 Illegal Function, STOP-mode behaviour, etc.) land
/// as separate test classes in this directory as each quirk is validated in pymodbus.
/// Keep this smoke test deliberately narrow — filtering by device class
/// (<c>--filter DisplayName~S7</c>) should surface the quirk-specific failure mode when
/// something goes wrong, not a blanket smoke failure that could mean anything.
/// </remarks>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7")]
public sealed class S7_1500SmokeTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task S7_1500_roundtrip_write_then_read_of_holding_register()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "s7_1500",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != s7_1500 — skipping (other profiles don't seed the S7 scratch range identically).");
}
var options = S7_1500Profile.BuildOptions(sim.Host, sim.Port);
await using var driver = new ModbusDriver(options, driverInstanceId: "s7-smoke");
await driver.InitializeAsync(driverConfigJson: "{}", TestContext.Current.CancellationToken);
var writeResults = await driver.WriteAsync(
[new(FullReference: "Smoke_HReg200", Value: (short)S7_1500Profile.SmokeHoldingValue)],
TestContext.Current.CancellationToken);
writeResults.Count.ShouldBe(1);
writeResults[0].StatusCode.ShouldBe(0u, "write must succeed against the S7-1500 MB_SERVER profile");
var readResults = await driver.ReadAsync(
["Smoke_HReg200"],
TestContext.Current.CancellationToken);
readResults.Count.ShouldBe(1);
readResults[0].StatusCode.ShouldBe(0u);
readResults[0].Value.ShouldBe((short)S7_1500Profile.SmokeHoldingValue);
}
}

View File

@@ -0,0 +1,132 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.IntegrationTests.S7;
/// <summary>
/// Verifies the Siemens S7 big-endian (<c>ABCD</c>) word-order default for Float32 and
/// Int32 against the <c>s7_1500.json</c> pymodbus profile. S7's native CPU types are
/// big-endian end-to-end, so <c>MB_SERVER</c> places the high word at the lower register
/// address — <b>opposite</b> of DL260's CDAB. The driver's S7-family tag config must
/// therefore default to <see cref="ModbusByteOrder.BigEndian"/>; selecting
/// <see cref="ModbusByteOrder.WordSwap"/> against an S7 would decode garbage.
/// </summary>
[Collection(ModbusSimulatorCollection.Name)]
[Trait("Category", "Integration")]
[Trait("Device", "S7")]
public sealed class S7_ByteOrderTests(ModbusSimulatorFixture sim)
{
[Fact]
public async Task S7_Float32_ABCD_decodes_1_5f_from_HR100()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "s7_1500",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != s7_1500 — skipping (s7_1500 profile is the only one seeding HR[100..101] ABCD).");
}
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("S7_Float_ABCD",
ModbusRegion.HoldingRegisters, Address: 100,
DataType: ModbusDataType.Float32, Writable: false,
ByteOrder: ModbusByteOrder.BigEndian),
// Control: same address with WordSwap should decode garbage — proves the
// two code paths diverge on S7 wire bytes.
new ModbusTagDefinition("S7_Float_CDAB_control",
ModbusRegion.HoldingRegisters, Address: 100,
DataType: ModbusDataType.Float32, Writable: false,
ByteOrder: ModbusByteOrder.WordSwap),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "s7-float-abcd");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(
["S7_Float_ABCD", "S7_Float_CDAB_control"],
TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(1.5f, "S7 MB_SERVER stores Float32 in ABCD word order; BigEndian decode returns 1.5f");
results[1].StatusCode.ShouldBe(0u);
results[1].Value.ShouldNotBe(1.5f, "applying CDAB swap to S7 ABCD bytes must produce a different value — confirms the flag is not a no-op and S7 profile default must be BigEndian");
}
[Fact]
public async Task S7_Int32_ABCD_decodes_0x12345678_from_HR300()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "s7_1500",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != s7_1500 — skipping.");
}
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("S7_Int32_ABCD",
ModbusRegion.HoldingRegisters, Address: 300,
DataType: ModbusDataType.Int32, Writable: false,
ByteOrder: ModbusByteOrder.BigEndian),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "s7-int-abcd");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["S7_Int32_ABCD"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe(0x12345678,
"S7 Int32 stored as HR[300]=0x1234, HR[301]=0x5678 with ABCD order decodes to 0x12345678 — DL260 would store the reverse order");
}
[Fact]
public async Task S7_DB1_fingerprint_marker_at_HR0_reads_0xABCD()
{
if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
if (!string.Equals(Environment.GetEnvironmentVariable("MODBUS_SIM_PROFILE"), "s7_1500",
StringComparison.OrdinalIgnoreCase))
{
Assert.Skip("MODBUS_SIM_PROFILE != s7_1500 — skipping.");
}
// Real-world MB_SERVER deployments typically reserve DB1.DBW0 as a fingerprint so
// clients can verify they're pointing at the right DB (protects against typos in
// the MB_SERVER.MB_HOLD_REG.DB_number parameter). 0xABCD is the convention.
var options = new ModbusDriverOptions
{
Host = sim.Host,
Port = sim.Port,
UnitId = 1,
Timeout = TimeSpan.FromSeconds(2),
Tags =
[
new ModbusTagDefinition("S7_Fingerprint",
ModbusRegion.HoldingRegisters, Address: 0,
DataType: ModbusDataType.UInt16, Writable: false),
],
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var driver = new ModbusDriver(options, driverInstanceId: "s7-fingerprint");
await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await driver.ReadAsync(["S7_Fingerprint"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
results[0].Value.ShouldBe((ushort)0xABCD);
}
}

View File

@@ -26,6 +26,8 @@
<ItemGroup>
<None Update="Pymodbus\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="DL205\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="S7\**\*" CopyToOutputDirectory="PreserveNewest"/>
<None Update="Mitsubishi\**\*" CopyToOutputDirectory="PreserveNewest"/>
</ItemGroup>
<ItemGroup>

View File

@@ -74,4 +74,66 @@ public sealed class DirectLogicAddressTests
Should.NotThrow(() => DirectLogicAddress.SystemVMemoryToPdu(0xDEFF));
Should.Throw<OverflowException>(() => DirectLogicAddress.SystemVMemoryToPdu(0xDF00));
}
// --- Bit memory: Y-output, C-relay, X-input, SP-special ---
[Theory]
[InlineData("Y0", (ushort)2048)]
[InlineData("Y1", (ushort)2049)]
[InlineData("Y7", (ushort)2055)]
[InlineData("Y10", (ushort)2056)] // octal 10 = decimal 8
[InlineData("Y17", (ushort)2063)] // octal 17 = decimal 15
[InlineData("Y777", (ushort)2559)] // top of DL260 Y range per doc table
public void YOutputToCoil_adds_octal_offset_to_2048(string y, ushort expected)
=> DirectLogicAddress.YOutputToCoil(y).ShouldBe(expected);
[Theory]
[InlineData("C0", (ushort)3072)]
[InlineData("C1", (ushort)3073)]
[InlineData("C10", (ushort)3080)]
[InlineData("C1777", (ushort)4095)] // top of DL260 C range
public void CRelayToCoil_adds_octal_offset_to_3072(string c, ushort expected)
=> DirectLogicAddress.CRelayToCoil(c).ShouldBe(expected);
[Theory]
[InlineData("X0", (ushort)0)]
[InlineData("X17", (ushort)15)]
[InlineData("X777", (ushort)511)] // top of DL260 X range
public void XInputToDiscrete_adds_octal_offset_to_0(string x, ushort expected)
=> DirectLogicAddress.XInputToDiscrete(x).ShouldBe(expected);
[Theory]
[InlineData("SP0", (ushort)1024)]
[InlineData("SP7", (ushort)1031)]
[InlineData("sp0", (ushort)1024)] // lowercase prefix
[InlineData("SP777", (ushort)1535)]
public void SpecialToDiscrete_adds_octal_offset_to_1024(string sp, ushort expected)
=> DirectLogicAddress.SpecialToDiscrete(sp).ShouldBe(expected);
[Theory]
[InlineData("Y8")]
[InlineData("C9")]
[InlineData("X18")]
public void Bit_address_rejects_non_octal_digits(string bad)
=> Should.Throw<ArgumentException>(() =>
{
if (bad[0] == 'Y') DirectLogicAddress.YOutputToCoil(bad);
else if (bad[0] == 'C') DirectLogicAddress.CRelayToCoil(bad);
else DirectLogicAddress.XInputToDiscrete(bad);
});
[Theory]
[InlineData("Y")]
[InlineData("C")]
[InlineData("")]
public void Bit_address_rejects_empty(string bad)
=> Should.Throw<ArgumentException>(() => DirectLogicAddress.YOutputToCoil(bad));
[Fact]
public void YOutputToCoil_accepts_lowercase_prefix()
=> DirectLogicAddress.YOutputToCoil("y0").ShouldBe((ushort)2048);
[Fact]
public void CRelayToCoil_accepts_bare_octal_without_C_prefix()
=> DirectLogicAddress.CRelayToCoil("0").ShouldBe((ushort)3072);
}

View File

@@ -0,0 +1,116 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class MelsecAddressTests
{
// --- X / Y hex vs octal family trap ---
[Theory]
[InlineData("X0", (ushort)0)]
[InlineData("X9", (ushort)9)]
[InlineData("XA", (ushort)10)] // hex
[InlineData("XF", (ushort)15)]
[InlineData("X10", (ushort)16)] // hex 0x10 = decimal 16
[InlineData("X20", (ushort)32)] // hex 0x20 = decimal 32 — the classic MELSEC-Q trap
[InlineData("X1FF", (ushort)511)]
[InlineData("x10", (ushort)16)] // lowercase prefix
public void XInputToDiscrete_QLiQR_parses_hex(string x, ushort expected)
=> MelsecAddress.XInputToDiscrete(x, MelsecFamily.Q_L_iQR).ShouldBe(expected);
[Theory]
[InlineData("X0", (ushort)0)]
[InlineData("X7", (ushort)7)]
[InlineData("X10", (ushort)8)] // octal 10 = decimal 8
[InlineData("X20", (ushort)16)] // octal 20 = decimal 16 — SAME string, DIFFERENT value on FX
[InlineData("X777", (ushort)511)]
public void XInputToDiscrete_FiQF_parses_octal(string x, ushort expected)
=> MelsecAddress.XInputToDiscrete(x, MelsecFamily.F_iQF).ShouldBe(expected);
[Theory]
[InlineData("Y0", (ushort)0)]
[InlineData("Y1F", (ushort)31)]
public void YOutputToCoil_QLiQR_parses_hex(string y, ushort expected)
=> MelsecAddress.YOutputToCoil(y, MelsecFamily.Q_L_iQR).ShouldBe(expected);
[Theory]
[InlineData("Y0", (ushort)0)]
[InlineData("Y17", (ushort)15)]
public void YOutputToCoil_FiQF_parses_octal(string y, ushort expected)
=> MelsecAddress.YOutputToCoil(y, MelsecFamily.F_iQF).ShouldBe(expected);
[Fact]
public void Same_address_string_decodes_differently_between_families()
{
// This is the headline quirk: "X20" in GX Works means one thing on Q-series and
// another on FX-series. The driver's family selector is the only defence.
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.Q_L_iQR).ShouldBe((ushort)32);
MelsecAddress.XInputToDiscrete("X20", MelsecFamily.F_iQF).ShouldBe((ushort)16);
}
[Theory]
[InlineData("X8")] // 8 is non-octal
[InlineData("X12G")] // G is non-hex
public void XInputToDiscrete_FiQF_rejects_non_octal(string bad)
=> Should.Throw<ArgumentException>(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.F_iQF));
[Theory]
[InlineData("X12G")]
public void XInputToDiscrete_QLiQR_rejects_non_hex(string bad)
=> Should.Throw<ArgumentException>(() => MelsecAddress.XInputToDiscrete(bad, MelsecFamily.Q_L_iQR));
[Fact]
public void XInputToDiscrete_honors_bank_base_from_assignment_block()
{
// Real-world QJ71MT91 assignment blocks commonly place X at DI 8192+ when other
// ranges take the low Modbus addresses. Helper must add the base cleanly.
MelsecAddress.XInputToDiscrete("X10", MelsecFamily.Q_L_iQR, xBankBase: 8192).ShouldBe((ushort)(8192 + 16));
}
// --- M-relay (decimal, both families) ---
[Theory]
[InlineData("M0", (ushort)0)]
[InlineData("M10", (ushort)10)] // M addresses are DECIMAL, not hex or octal
[InlineData("M511", (ushort)511)]
[InlineData("m99", (ushort)99)] // lowercase
public void MRelayToCoil_parses_decimal(string m, ushort expected)
=> MelsecAddress.MRelayToCoil(m).ShouldBe(expected);
[Fact]
public void MRelayToCoil_honors_bank_base()
=> MelsecAddress.MRelayToCoil("M0", mBankBase: 512).ShouldBe((ushort)512);
[Fact]
public void MRelayToCoil_rejects_non_numeric()
=> Should.Throw<ArgumentException>(() => MelsecAddress.MRelayToCoil("M1F"));
// --- D-register (decimal, both families) ---
[Theory]
[InlineData("D0", (ushort)0)]
[InlineData("D100", (ushort)100)]
[InlineData("d1023", (ushort)1023)]
public void DRegisterToHolding_parses_decimal(string d, ushort expected)
=> MelsecAddress.DRegisterToHolding(d).ShouldBe(expected);
[Fact]
public void DRegisterToHolding_honors_bank_base()
=> MelsecAddress.DRegisterToHolding("D10", dBankBase: 4096).ShouldBe((ushort)4106);
[Fact]
public void DRegisterToHolding_rejects_empty()
=> Should.Throw<ArgumentException>(() => MelsecAddress.DRegisterToHolding("D"));
// --- overflow ---
[Fact]
public void XInputToDiscrete_overflow_throws()
{
// 0xFFFF + base 1 = 0x10000 — past ushort.
Should.Throw<OverflowException>(() =>
MelsecAddress.XInputToDiscrete("XFFFF", MelsecFamily.Q_L_iQR, xBankBase: 1));
}
}

View File

@@ -0,0 +1,165 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
[Trait("Category", "Unit")]
public sealed class ModbusCapTests
{
/// <summary>
/// Records every PDU sent so tests can assert request-count and per-request quantity —
/// the only observable behaviour of the auto-chunking path.
/// </summary>
private sealed class RecordingTransport : IModbusTransport
{
public readonly ushort[] HoldingRegisters = new ushort[1024];
public readonly List<(ushort Address, ushort Quantity)> Fc03Requests = new();
public readonly List<(ushort Address, ushort Quantity)> Fc16Requests = new();
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
var fc = pdu[0];
if (fc == 0x03)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
Fc03Requests.Add((addr, qty));
var byteCount = (byte)(qty * 2);
var resp = new byte[2 + byteCount];
resp[0] = 0x03;
resp[1] = byteCount;
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);
}
if (fc == 0x10)
{
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
Fc16Requests.Add((addr, qty));
for (var i = 0; i < qty; i++)
HoldingRegisters[addr + i] = (ushort)((pdu[6 + i * 2] << 8) | pdu[7 + i * 2]);
return Task.FromResult(new byte[] { 0x10, pdu[1], pdu[2], pdu[3], pdu[4] });
}
return Task.FromException<byte[]>(new ModbusException(fc, 0x01, $"fc={fc} unsupported"));
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task Read_within_cap_issues_single_FC03_request()
{
var tag = new ModbusTagDefinition("S", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 40); // 20 regs — fits in default cap (125).
var transport = new RecordingTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["S"], TestContext.Current.CancellationToken);
transport.Fc03Requests.Count.ShouldBe(1);
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)20);
}
[Fact]
public async Task Read_above_cap_splits_into_two_FC03_requests()
{
// 240-char string = 120 regs. Cap = 100 (a typical sub-spec device cap). Expect 100 + 20.
var tag = new ModbusTagDefinition("LongString", ModbusRegion.HoldingRegisters, 100, ModbusDataType.String,
StringLength: 240);
var transport = new RecordingTransport();
// Seed cells so the re-assembled payload is stable — confirms chunks are stitched in order.
for (ushort i = 100; i < 100 + 120; i++)
transport.HoldingRegisters[i] = (ushort)((('A' + (i - 100) % 26) << 8) | ('A' + (i - 100) % 26));
var opts = new ModbusDriverOptions
{
Host = "fake",
Tags = [tag],
MaxRegistersPerRead = 100,
Probe = new ModbusProbeOptions { Enabled = false },
};
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.ReadAsync(["LongString"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
transport.Fc03Requests.Count.ShouldBe(2, "120 regs / cap 100 → 2 requests");
transport.Fc03Requests[0].ShouldBe(((ushort)100, (ushort)100));
transport.Fc03Requests[1].ShouldBe(((ushort)200, (ushort)20));
// Payload continuity: re-assembled string starts where register 100 does and keeps going.
var s = (string)results[0].Value!;
s.Length.ShouldBeGreaterThan(0);
s[0].ShouldBe('A'); // register[100] high byte
}
[Fact]
public async Task Read_cap_honors_Mitsubishi_lower_cap_of_64()
{
// 200-char string = 100 regs. Mitsubishi Q cap = 64. Expect: 64, 36.
var tag = new ModbusTagDefinition("MitString", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 200);
var transport = new RecordingTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerRead = 64, Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
_ = await drv.ReadAsync(["MitString"], TestContext.Current.CancellationToken);
transport.Fc03Requests.Count.ShouldBe(2);
transport.Fc03Requests[0].Quantity.ShouldBe((ushort)64);
transport.Fc03Requests[1].Quantity.ShouldBe((ushort)36);
}
[Fact]
public async Task Write_exceeding_cap_throws_instead_of_splitting()
{
// Partial FC16 across two transactions is not atomic. Forcing an explicit exception so the
// caller knows their tag definition is incompatible with the device cap rather than silently
// writing half a string and crashing between chunks.
var tag = new ModbusTagDefinition("LongStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 220); // 110 regs.
var transport = new RecordingTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[new WriteRequest("LongStringWrite", new string('A', 220))],
TestContext.Current.CancellationToken);
// Driver catches the internal exception and surfaces BadInternalError — the Fc16Requests
// list must still be empty because nothing was sent.
results[0].StatusCode.ShouldNotBe(0u);
transport.Fc16Requests.Count.ShouldBe(0);
}
[Fact]
public async Task Write_within_cap_proceeds_normally()
{
var tag = new ModbusTagDefinition("ShortStringWrite", ModbusRegion.HoldingRegisters, 0, ModbusDataType.String,
StringLength: 40); // 20 regs.
var transport = new RecordingTransport();
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], MaxRegistersPerWrite = 100, Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.WriteAsync(
[new WriteRequest("ShortStringWrite", "HELLO")],
TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0u);
transport.Fc16Requests.Count.ShouldBe(1);
transport.Fc16Requests[0].Quantity.ShouldBe((ushort)20);
}
}

View File

@@ -0,0 +1,88 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Unit tests for the Modbus-exception-code → OPC UA StatusCode mapping added in PR 52.
/// Before PR 52 every server exception + every transport failure collapsed to
/// BadInternalError (0x80020000), which made field diagnosis "is this a bad tag or a bad
/// driver?" impossible. These tests lock in the translation table documented on
/// <see cref="ModbusDriver.MapModbusExceptionToStatus"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusExceptionMapperTests
{
[Theory]
[InlineData((byte)0x01, 0x803D0000u)] // Illegal Function → BadNotSupported
[InlineData((byte)0x02, 0x803C0000u)] // Illegal Data Address → BadOutOfRange
[InlineData((byte)0x03, 0x803C0000u)] // Illegal Data Value → BadOutOfRange
[InlineData((byte)0x04, 0x80550000u)] // Server Failure → BadDeviceFailure
[InlineData((byte)0x05, 0x80550000u)] // Acknowledge (long op) → BadDeviceFailure
[InlineData((byte)0x06, 0x80550000u)] // Server Busy → BadDeviceFailure
[InlineData((byte)0x0A, 0x80050000u)] // Gateway path unavailable → BadCommunicationError
[InlineData((byte)0x0B, 0x80050000u)] // Gateway target failed to respond → BadCommunicationError
[InlineData((byte)0xFF, 0x80020000u)] // Unknown code → BadInternalError fallback
public void MapModbusExceptionToStatus_returns_informative_status(byte code, uint expected)
=> ModbusDriver.MapModbusExceptionToStatus(code).ShouldBe(expected);
private sealed class ExceptionRaisingTransport(byte exceptionCode) : IModbusTransport
{
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
=> Task.FromException<byte[]>(new ModbusException(pdu[0], exceptionCode, $"fc={pdu[0]} code={exceptionCode}"));
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task Read_surface_exception_02_as_BadOutOfRange_not_BadInternalError()
{
var transport = new ExceptionRaisingTransport(exceptionCode: 0x02);
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0x803C0000u, "FC03 at an unmapped register must bubble out as BadOutOfRange so operators can spot a bad tag config");
}
[Fact]
public async Task Write_surface_exception_04_as_BadDeviceFailure()
{
var transport = new ExceptionRaisingTransport(exceptionCode: 0x04);
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => transport);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var writes = await drv.WriteAsync(
[new WriteRequest("T", (short)42)],
TestContext.Current.CancellationToken);
writes[0].StatusCode.ShouldBe(0x80550000u, "FC06 returning exception 04 (CPU in PROGRAM mode) maps to BadDeviceFailure");
}
private sealed class NonModbusFailureTransport : IModbusTransport
{
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
=> Task.FromException<byte[]>(new EndOfStreamException("socket closed mid-response"));
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
[Fact]
public async Task Read_non_modbus_failure_maps_to_BadCommunicationError_not_BadInternalError()
{
// Socket drop / timeout / malformed frame → transport-layer failure. Should surface
// distinctly from tag-level faults so operators know to check the network, not the config.
var tag = new ModbusTagDefinition("T", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16);
var opts = new ModbusDriverOptions { Host = "fake", Tags = [tag], Probe = new ModbusProbeOptions { Enabled = false } };
await using var drv = new ModbusDriver(opts, "modbus-1", _ => new NonModbusFailureTransport());
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var results = await drv.ReadAsync(["T"], TestContext.Current.CancellationToken);
results[0].StatusCode.ShouldBe(0x80050000u);
}
}

View File

@@ -0,0 +1,146 @@
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Exercises <see cref="ModbusTcpTransport"/> against a real TCP listener that can close
/// its socket mid-session on demand. Verifies the PR 53 reconnect-on-drop behavior: after
/// the "first" socket is forcibly torn down, the next SendAsync must re-establish the
/// connection and complete the PDU without bubbling an error to the caller.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusTcpReconnectTests
{
/// <summary>
/// Minimal in-process Modbus-TCP stub. Accepts one TCP connection at a time, reads an
/// MBAP + PDU, replies with a canned FC03 response echoing the request quantity of
/// zeroed bytes, then optionally closes the socket to simulate a NAT/firewall drop.
/// </summary>
private sealed class FlakeyModbusServer : IAsyncDisposable
{
private readonly TcpListener _listener;
public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port;
public int DropAfterNTransactions { get; set; } = int.MaxValue;
private readonly CancellationTokenSource _stop = new();
private int _txCount;
public FlakeyModbusServer()
{
_listener = new TcpListener(IPAddress.Loopback, 0);
_listener.Start();
_ = Task.Run(AcceptLoopAsync);
}
private async Task AcceptLoopAsync()
{
while (!_stop.IsCancellationRequested)
{
TcpClient? client = null;
try { client = await _listener.AcceptTcpClientAsync(_stop.Token); }
catch { return; }
_ = Task.Run(() => ServeAsync(client!));
}
}
private async Task ServeAsync(TcpClient client)
{
try
{
using var _ = client;
var stream = client.GetStream();
while (!_stop.IsCancellationRequested && client.Connected)
{
var header = new byte[7];
if (!await ReadExactly(stream, header)) return;
var len = (ushort)((header[4] << 8) | header[5]);
var pdu = new byte[len - 1];
if (!await ReadExactly(stream, pdu)) return;
var fc = pdu[0];
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
var respPdu = new byte[2 + qty * 2];
respPdu[0] = fc;
respPdu[1] = (byte)(qty * 2);
// data bytes stay 0
var respLen = (ushort)(1 + respPdu.Length);
var adu = new byte[7 + respPdu.Length];
adu[0] = header[0]; adu[1] = header[1];
adu[4] = (byte)(respLen >> 8); adu[5] = (byte)(respLen & 0xFF);
adu[6] = header[6];
Buffer.BlockCopy(respPdu, 0, adu, 7, respPdu.Length);
await stream.WriteAsync(adu);
await stream.FlushAsync();
_txCount++;
if (_txCount >= DropAfterNTransactions)
{
// Simulate NAT/firewall silent close: slam the socket without a
// protocol-level goodbye, which is what DL260 + an intermediate
// middlebox would look like from the client's perspective.
client.Client.Shutdown(SocketShutdown.Both);
client.Close();
return;
}
}
}
catch { /* best-effort */ }
}
private static async Task<bool> ReadExactly(NetworkStream s, byte[] buf)
{
var read = 0;
while (read < buf.Length)
{
var n = await s.ReadAsync(buf.AsMemory(read));
if (n == 0) return false;
read += n;
}
return true;
}
public async ValueTask DisposeAsync()
{
_stop.Cancel();
_listener.Stop();
await Task.CompletedTask;
}
}
[Fact]
public async Task Transport_recovers_from_mid_session_drop_and_retries_successfully()
{
await using var server = new FlakeyModbusServer { DropAfterNTransactions = 1 };
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: true);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
// First transaction succeeds; server then closes the socket.
var pdu = new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 };
var first = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken);
first[0].ShouldBe((byte)0x03);
// Second transaction: the connection is dead, but auto-reconnect must transparently
// spin up a new socket, resend, and produce a valid response. Before PR 53 this would
// surface as EndOfStreamException / IOException to the caller.
var second = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken);
second[0].ShouldBe((byte)0x03);
}
[Fact]
public async Task Transport_without_AutoReconnect_propagates_drop_to_caller()
{
await using var server = new FlakeyModbusServer { DropAfterNTransactions = 1 };
await using var transport = new ModbusTcpTransport("127.0.0.1", server.Port, TimeSpan.FromSeconds(2), autoReconnect: false);
await transport.ConnectAsync(TestContext.Current.CancellationToken);
var pdu = new byte[] { 0x03, 0x00, 0x00, 0x00, 0x01 };
_ = await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken);
await Should.ThrowAsync<Exception>(async () =>
await transport.SendAsync(unitId: 1, pdu, TestContext.Current.CancellationToken));
}
}

View File

@@ -0,0 +1,55 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Scaffold tests for <see cref="OpcUaClientDriver"/>'s <see cref="ITagDiscovery"/>
/// surface that don't require a live remote server. Live-browse coverage lands in a
/// follow-up PR once the in-process OPC UA server fixture is scaffolded.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientDiscoveryTests
{
[Fact]
public async Task DiscoverAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-disco");
var builder = new NullAddressSpaceBuilder();
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken));
}
[Fact]
public void DiscoverAsync_rejects_null_builder()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-disco");
Should.ThrowAsync<ArgumentNullException>(async () =>
await drv.DiscoverAsync(null!, TestContext.Current.CancellationToken));
}
[Fact]
public void Discovery_caps_are_sensible_defaults()
{
var opts = new OpcUaClientDriverOptions();
opts.MaxDiscoveredNodes.ShouldBe(10_000, "bounds memory on runaway servers without clipping normal models");
opts.MaxBrowseDepth.ShouldBe(10, "deep enough for realistic info models; shallow enough for cycle safety");
opts.BrowseRoot.ShouldBeNull("null = default to ObjectsFolder i=85");
}
private sealed class NullAddressSpaceBuilder : IAddressSpaceBuilder
{
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
=> new StubHandle();
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
private sealed class StubHandle : IVariableHandle
{
public string FullReference => "stub";
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException();
}
}
}

View File

@@ -0,0 +1,91 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Scaffold-level tests for <see cref="OpcUaClientDriver"/> that don't require a live
/// remote OPC UA server. PR 67+ adds IReadable/IWritable/ITagDiscovery/ISubscribable
/// tests against a local in-process OPC UA server fixture.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientDriverScaffoldTests
{
[Fact]
public void Default_options_target_standard_opcua_port_and_anonymous_auth()
{
var opts = new OpcUaClientDriverOptions();
opts.EndpointUrl.ShouldBe("opc.tcp://localhost:4840", "4840 is the IANA-assigned OPC UA port");
opts.SecurityMode.ShouldBe(OpcUaSecurityMode.None);
opts.SecurityPolicy.ShouldBe(OpcUaSecurityPolicy.None);
opts.AuthType.ShouldBe(OpcUaAuthType.Anonymous);
opts.AutoAcceptCertificates.ShouldBeFalse("production default must reject untrusted server certs");
}
[Fact]
public void Default_timeouts_match_driver_specs_section_8()
{
var opts = new OpcUaClientDriverOptions();
opts.SessionTimeout.ShouldBe(TimeSpan.FromSeconds(120));
opts.KeepAliveInterval.ShouldBe(TimeSpan.FromSeconds(5));
opts.ReconnectPeriod.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void Driver_reports_type_and_id_before_connect()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-test");
drv.DriverType.ShouldBe("OpcUaClient");
drv.DriverInstanceId.ShouldBe("opcua-test");
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
}
[Fact]
public async Task Initialize_against_unreachable_endpoint_transitions_to_Faulted_and_throws()
{
// RFC 5737 reserved-for-documentation IP; won't route anywhere. Pick opc.tcp:// so
// endpoint selection hits the transport-layer connection rather than a DNS lookup.
var opts = new OpcUaClientDriverOptions
{
// Port 1 on loopback is effectively guaranteed to be closed — the OS responds
// with TCP RST immediately instead of hanging on connect, which keeps the
// unreachable-host tests snappy. Don't use an RFC 5737 reserved IP; those get
// routed to a black-hole + time out only after the SDK's internal retry/backoff
// fully elapses (~60s even with Options.Timeout=500ms).
EndpointUrl = "opc.tcp://127.0.0.1:1",
Timeout = TimeSpan.FromMilliseconds(500),
AutoAcceptCertificates = true, // dev-mode to bypass cert validation in the test
};
using var drv = new OpcUaClientDriver(opts, "opcua-unreach");
await Should.ThrowAsync<Exception>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Faulted);
health.LastError.ShouldNotBeNull();
}
[Fact]
public async Task Reinitialize_against_unreachable_endpoint_re_throws()
{
var opts = new OpcUaClientDriverOptions
{
// Port 1 on loopback is effectively guaranteed to be closed — the OS responds
// with TCP RST immediately instead of hanging on connect, which keeps the
// unreachable-host tests snappy. Don't use an RFC 5737 reserved IP; those get
// routed to a black-hole + time out only after the SDK's internal retry/backoff
// fully elapses (~60s even with Options.Timeout=500ms).
EndpointUrl = "opc.tcp://127.0.0.1:1",
Timeout = TimeSpan.FromMilliseconds(500),
AutoAcceptCertificates = true,
};
using var drv = new OpcUaClientDriver(opts, "opcua-reinit");
await Should.ThrowAsync<Exception>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
await Should.ThrowAsync<Exception>(async () =>
await drv.ReinitializeAsync("{}", TestContext.Current.CancellationToken));
}
}

View File

@@ -0,0 +1,32 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Unit tests for the IReadable/IWritable surface that don't need a live remote OPC UA
/// server. Wire-level round-trips against a local in-process server fixture land in a
/// follow-up PR once we have one scaffolded.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientReadWriteTests
{
[Fact]
public async Task ReadAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadAsync(["ns=2;s=Demo"], TestContext.Current.CancellationToken));
}
[Fact]
public async Task WriteAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.WriteAsync(
[new WriteRequest("ns=2;s=Demo", 42)],
TestContext.Current.CancellationToken));
}
}

View File

@@ -0,0 +1,54 @@
using Opc.Ua;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
[Trait("Category", "Unit")]
public sealed class OpcUaClientSecurityPolicyTests
{
[Theory]
[InlineData(OpcUaSecurityPolicy.None)]
[InlineData(OpcUaSecurityPolicy.Basic128Rsa15)]
[InlineData(OpcUaSecurityPolicy.Basic256)]
[InlineData(OpcUaSecurityPolicy.Basic256Sha256)]
[InlineData(OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep)]
[InlineData(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)]
public void MapSecurityPolicy_returns_known_non_empty_uri_for_every_enum_value(OpcUaSecurityPolicy policy)
{
var uri = OpcUaClientDriver.MapSecurityPolicy(policy);
uri.ShouldNotBeNullOrEmpty();
// Each URI should end in the enum name (for the non-None policies) so a driver
// operator reading logs can correlate the URI back to the config value.
if (policy != OpcUaSecurityPolicy.None)
uri.ShouldContain(policy.ToString());
}
[Fact]
public void MapSecurityPolicy_None_matches_SDK_None_URI()
{
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.None)
.ShouldBe(SecurityPolicies.None);
}
[Fact]
public void MapSecurityPolicy_Basic256Sha256_matches_SDK_URI()
{
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Basic256Sha256)
.ShouldBe(SecurityPolicies.Basic256Sha256);
}
[Fact]
public void MapSecurityPolicy_Aes256_Sha256_RsaPss_matches_SDK_URI()
{
OpcUaClientDriver.MapSecurityPolicy(OpcUaSecurityPolicy.Aes256_Sha256_RsaPss)
.ShouldBe(SecurityPolicies.Aes256_Sha256_RsaPss);
}
[Fact]
public void Every_enum_value_has_a_mapping()
{
foreach (OpcUaSecurityPolicy p in Enum.GetValues<OpcUaSecurityPolicy>())
Should.NotThrow(() => OpcUaClientDriver.MapSecurityPolicy(p));
}
}

View File

@@ -0,0 +1,50 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Scaffold tests for <c>ISubscribable</c> + <c>IHostConnectivityProbe</c> that don't
/// need a live remote server. Live-session tests (subscribe/unsubscribe round-trip,
/// keep-alive transitions) land in a follow-up PR once the in-process OPC UA server
/// fixture is scaffolded.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientSubscribeAndProbeTests
{
[Fact]
public async Task SubscribeAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sub-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.SubscribeAsync(["ns=2;s=Demo"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken));
}
[Fact]
public async Task UnsubscribeAsync_with_unknown_handle_is_noop()
{
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-sub-unknown");
// UnsubscribeAsync returns cleanly for handles it doesn't recognise — protects against
// the caller's race with server-side cleanup after a session drop.
await drv.UnsubscribeAsync(new FakeHandle(), TestContext.Current.CancellationToken);
}
[Fact]
public void GetHostStatuses_returns_endpoint_url_row_pre_init()
{
using var drv = new OpcUaClientDriver(
new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://plc.example:4840" },
"opcua-hosts");
var rows = drv.GetHostStatuses();
rows.Count.ShouldBe(1);
rows[0].HostName.ShouldBe("opc.tcp://plc.example:4840",
"host identity mirrors the endpoint URL so the Admin /hosts dashboard can link back to the remote server");
rows[0].State.ShouldBe(HostState.Unknown);
}
private sealed class FakeHandle : ISubscriptionHandle
{
public string DiagnosticId => "fake";
}
}

View File

@@ -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.OpcUaClient.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.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.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>

View File

@@ -0,0 +1,119 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
[Trait("Category", "Unit")]
public sealed class S7AddressParserTests
{
// --- Data blocks ---
[Theory]
[InlineData("DB1.DBX0.0", 1, S7Size.Bit, 0, 0)]
[InlineData("DB1.DBX0.7", 1, S7Size.Bit, 0, 7)]
[InlineData("DB1.DBB0", 1, S7Size.Byte, 0, 0)]
[InlineData("DB1.DBW0", 1, S7Size.Word, 0, 0)]
[InlineData("DB1.DBD4", 1, S7Size.DWord, 4, 0)]
[InlineData("DB10.DBW100", 10, S7Size.Word, 100, 0)]
[InlineData("DB1.DBX15.3", 1, S7Size.Bit, 15, 3)]
public void Parse_data_block_addresses(string input, int db, S7Size size, int byteOff, int bitOff)
{
var r = S7AddressParser.Parse(input);
r.Area.ShouldBe(S7Area.DataBlock);
r.DbNumber.ShouldBe(db);
r.Size.ShouldBe(size);
r.ByteOffset.ShouldBe(byteOff);
r.BitOffset.ShouldBe(bitOff);
}
[Theory]
[InlineData("db1.dbw0", 1, S7Size.Word, 0)]
[InlineData(" DB1.DBW0 ", 1, S7Size.Word, 0)] // trim whitespace
public void Parse_is_case_insensitive_and_trims(string input, int db, S7Size size, int off)
{
var r = S7AddressParser.Parse(input);
r.Area.ShouldBe(S7Area.DataBlock);
r.DbNumber.ShouldBe(db);
r.Size.ShouldBe(size);
r.ByteOffset.ShouldBe(off);
}
// --- M / I / Q ---
[Theory]
[InlineData("MB0", S7Area.Memory, S7Size.Byte, 0, 0)]
[InlineData("MW10", S7Area.Memory, S7Size.Word, 10, 0)]
[InlineData("MD4", S7Area.Memory, S7Size.DWord, 4, 0)]
[InlineData("M0.0", S7Area.Memory, S7Size.Bit, 0, 0)]
[InlineData("M255.7", S7Area.Memory, S7Size.Bit, 255, 7)]
[InlineData("IB0", S7Area.Input, S7Size.Byte, 0, 0)]
[InlineData("IW0", S7Area.Input, S7Size.Word, 0, 0)]
[InlineData("I0.0", S7Area.Input, S7Size.Bit, 0, 0)]
[InlineData("QB0", S7Area.Output, S7Size.Byte, 0, 0)]
[InlineData("QW0", S7Area.Output, S7Size.Word, 0, 0)]
[InlineData("Q0.0", S7Area.Output, S7Size.Bit, 0, 0)]
[InlineData("QD4", S7Area.Output, S7Size.DWord, 4, 0)]
public void Parse_MIQ_addresses(string input, S7Area area, S7Size size, int byteOff, int bitOff)
{
var r = S7AddressParser.Parse(input);
r.Area.ShouldBe(area);
r.DbNumber.ShouldBe(0);
r.Size.ShouldBe(size);
r.ByteOffset.ShouldBe(byteOff);
r.BitOffset.ShouldBe(bitOff);
}
// --- Timers / counters ---
[Theory]
[InlineData("T0", S7Area.Timer, 0)]
[InlineData("T15", S7Area.Timer, 15)]
[InlineData("C0", S7Area.Counter, 0)]
[InlineData("C10", S7Area.Counter, 10)]
public void Parse_timer_and_counter(string input, S7Area area, int number)
{
var r = S7AddressParser.Parse(input);
r.Area.ShouldBe(area);
r.ByteOffset.ShouldBe(number);
r.Size.ShouldBe(S7Size.Word, "timers + counters are 16-bit opaque");
}
// --- Reject garbage ---
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("Z0")] // unknown area
[InlineData("DB")] // no number or tail
[InlineData("DB1")] // no tail
[InlineData("DB1.")] // empty tail
[InlineData("DB1.DBX0")] // bit size without .bit
[InlineData("DB1.DBX0.8")] // bit 8 out of range
[InlineData("DB1.DBW0.0")] // word with bit suffix
[InlineData("DB0.DBW0")] // db 0 invalid
[InlineData("DBA.DBW0")] // non-numeric db
[InlineData("DB1.DBQ0")] // invalid size letter
[InlineData("M")] // no offset
[InlineData("M0")] // bit access needs .bit
[InlineData("M0.8")] // bit 8
[InlineData("MB-1")] // negative offset
[InlineData("MW")] // no offset digits
[InlineData("TA")] // non-numeric timer
public void Parse_rejects_invalid(string bad)
=> Should.Throw<FormatException>(() => S7AddressParser.Parse(bad));
[Fact]
public void TryParse_returns_false_for_garbage_without_throwing()
{
S7AddressParser.TryParse("not-an-address", out var r).ShouldBeFalse();
r.ShouldBe(default);
}
[Fact]
public void TryParse_returns_true_for_valid_address()
{
S7AddressParser.TryParse("DB1.DBW0", out var r).ShouldBeTrue();
r.DbNumber.ShouldBe(1);
r.Size.ShouldBe(S7Size.Word);
}
}

View File

@@ -0,0 +1,117 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Shape tests for <see cref="S7Driver"/>'s <see cref="ITagDiscovery"/>,
/// <see cref="ISubscribable"/>, and <see cref="IHostConnectivityProbe"/> surfaces that
/// don't need a live PLC. Wire-level polling round-trips and probe transitions land in a
/// follow-up PR once we have a mock S7 server.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DiscoveryAndSubscribeTests
{
private sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
{
public readonly List<string> Folders = new();
public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
public IAddressSpaceBuilder Folder(string browseName, string displayName)
{
Folders.Add(browseName);
return this;
}
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new StubHandle();
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
private sealed class StubHandle : IVariableHandle
{
public string FullReference => "stub";
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
=> throw new NotImplementedException("S7 driver never calls this — no alarm surfacing");
}
}
[Fact]
public async Task DiscoverAsync_projects_every_tag_into_the_address_space()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Tags =
[
new("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true),
new("FaultBit", "M0.0", S7DataType.Bool, Writable: false),
new("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true),
],
};
using var drv = new S7Driver(opts, "s7-disco");
var builder = new RecordingAddressSpaceBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Folders.ShouldContain("S7");
builder.Variables.Count.ShouldBe(3);
builder.Variables[0].Name.ShouldBe("TempSetpoint");
builder.Variables[0].Attr.SecurityClass.ShouldBe(SecurityClassification.Operate, "writable tags get Operate security class");
builder.Variables[1].Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly, "read-only tags get ViewOnly");
builder.Variables[2].Attr.DriverDataType.ShouldBe(DriverDataType.Float32);
}
[Fact]
public void GetHostStatuses_returns_one_row_with_host_port_identity_pre_init()
{
var opts = new S7DriverOptions { Host = "plc1.internal", Port = 102 };
using var drv = new S7Driver(opts, "s7-host");
var rows = drv.GetHostStatuses();
rows.Count.ShouldBe(1);
rows[0].HostName.ShouldBe("plc1.internal:102");
rows[0].State.ShouldBe(HostState.Unknown, "pre-init / pre-probe state is Unknown");
}
[Fact]
public async Task SubscribeAsync_returns_unique_handles_and_UnsubscribeAsync_accepts_them()
{
var opts = new S7DriverOptions { Host = "192.0.2.1" };
using var drv = new S7Driver(opts, "s7-sub");
// SubscribeAsync does not itself call ReadAsync (the poll task does), so this works
// even though the driver isn't initialized. The poll task catches the resulting
// InvalidOperationException and the loop quietly continues — same pattern as the
// Modbus driver's poll loop tolerating transient transport failures.
var h1 = await drv.SubscribeAsync(["T1"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
var h2 = await drv.SubscribeAsync(["T2"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
h1.DiagnosticId.ShouldStartWith("s7-sub-");
h2.DiagnosticId.ShouldStartWith("s7-sub-");
h1.DiagnosticId.ShouldNotBe(h2.DiagnosticId);
await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
await drv.UnsubscribeAsync(h2, TestContext.Current.CancellationToken);
// UnsubscribeAsync with an unknown handle must be a no-op, not throw.
await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
}
[Fact]
public async Task Subscribe_publishing_interval_is_floored_at_100ms()
{
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
using var drv = new S7Driver(opts, "s7-floor");
// 50 ms requested — the floor protects the S7 CPU from sub-scan polling that would
// just queue wire-side. Test that the subscription is accepted (the floor is applied
// internally; the floor value isn't exposed, so we're really just asserting that the
// driver doesn't reject small intervals).
var h = await drv.SubscribeAsync(["T"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
h.ShouldNotBeNull();
await drv.UnsubscribeAsync(h, TestContext.Current.CancellationToken);
}
}

View File

@@ -0,0 +1,54 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Unit tests for <see cref="S7Driver"/>'s <c>IReadable</c>/<c>IWritable</c> surface
/// that don't require a live PLC — covers error paths (not-initialized, unknown tag,
/// read-only write rejection, unsupported data types). Wire-level round-trip tests
/// against a live S7 or a mock-server land in a follow-up PR since S7.Net doesn't ship
/// an in-process fake and an adequate mock is non-trivial.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DriverReadWriteTests
{
[Fact]
public async Task Initialize_rejects_invalid_tag_address_and_fails_fast()
{
// Bad address at init time must throw; the alternative (deferring the parse to the
// first read) would surface the config bug as BadInternalError on every subsequent
// Read which is impossible for an operator to diagnose from the OPC UA client.
var opts = new S7DriverOptions
{
Host = "192.0.2.1", // reserved — will never complete TCP handshake
Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)],
};
using var drv = new S7Driver(opts, "s7-bad-tag");
// Either the TCP connect fails first (Exception) or the parser fails (FormatException)
// — both are acceptable since both are init-time fail-fast. What matters is that we
// don't return a "healthy" driver with a latent bad tag.
await Should.ThrowAsync<Exception>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
}
[Fact]
public async Task ReadAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.ReadAsync(["Any"], TestContext.Current.CancellationToken));
}
[Fact]
public async Task WriteAsync_without_initialize_throws_InvalidOperationException()
{
using var drv = new S7Driver(new S7DriverOptions { Host = "192.0.2.1" }, "s7-uninit");
await Should.ThrowAsync<InvalidOperationException>(async () =>
await drv.WriteAsync(
[new(FullReference: "Any", Value: (short)0)],
TestContext.Current.CancellationToken));
}
}

View File

@@ -0,0 +1,66 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
/// <summary>
/// Scaffold-level tests that don't need a live S7 PLC — exercise driver lifecycle shape,
/// default option values, and failure-mode transitions. PR 64 adds IReadable/IWritable
/// tests against a mock-server, PR 65 adds discovery + subscribe.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DriverScaffoldTests
{
[Fact]
public void Default_options_target_S7_1500_slot_0_on_port_102()
{
var opts = new S7DriverOptions();
opts.Port.ShouldBe(102, "ISO-on-TCP is always 102 for S7; documented in driver-specs.md §5");
opts.CpuType.ShouldBe(global::S7.Net.CpuType.S71500);
opts.Rack.ShouldBe((short)0);
opts.Slot.ShouldBe((short)0, "S7-1200/1500 onboard PN ports are slot 0 by convention");
}
[Fact]
public void Default_probe_interval_is_reasonable_for_S7_scan_cycle()
{
// S7 PLCs scan 2-10 ms but comms mailbox typically processed once per scan.
// 5 s default probe is lightweight — ~0.001% of comms budget.
new S7ProbeOptions().Interval.ShouldBe(TimeSpan.FromSeconds(5));
}
[Fact]
public void Tag_definition_defaults_to_writable_with_S7_max_string_length()
{
var tag = new S7TagDefinition("T", "DB1.DBW0", S7DataType.Int16);
tag.Writable.ShouldBeTrue();
tag.StringLength.ShouldBe(254, "S7 STRING type max length is 254 chars");
}
[Fact]
public void Driver_instance_reports_type_and_id_before_connect()
{
var opts = new S7DriverOptions { Host = "127.0.0.1" };
using var drv = new S7Driver(opts, "s7-test");
drv.DriverType.ShouldBe("S7");
drv.DriverInstanceId.ShouldBe("s7-test");
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "health starts Unknown until InitializeAsync runs");
}
[Fact]
public async Task Initialize_against_unreachable_host_transitions_to_Faulted_and_throws()
{
// Pick an RFC 5737 reserved-for-documentation IP so the connect attempt fails fast
// (no DNS mismatch, no accidental traffic to a real PLC).
var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) };
using var drv = new S7Driver(opts, "s7-unreach");
await Should.ThrowAsync<Exception>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Faulted, "unreachable host must flip the driver to Faulted so operators see it");
health.LastError.ShouldNotBeNull();
}
}

View File

@@ -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.S7.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.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.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>