Compare commits

...

22 Commits

Author SHA1 Message Date
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
19 changed files with 2351 additions and 44 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"/>

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.

View File

@@ -0,0 +1,666 @@
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 useSecurity = _options.SecurityMode != OpcUaSecurityMode.None;
// The non-obsolete SelectEndpointAsync overloads all require an ITelemetryContext
// parameter. Passing null is valid — the SDK falls through to its built-in default
// trace sink. Plumbing a telemetry context through every driver surface is out of
// scope; the driver emits its own logs via the health surface anyway.
var selected = await CoreClientUtils.SelectEndpointAsync(
appConfig, _options.EndpointUrl, useSecurity,
telemetry: null!,
ct: 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;
}
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,105 @@
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. One of <c>None</c>, <c>Basic256Sha256</c>, <c>Aes128_Sha256_RsaOaep</c>.</summary>
public string SecurityPolicy { get; init; } = "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>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,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,90 @@
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.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,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>