dohertj2 dohertj2
  • Joined on 2026-02-20
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-19 01:15:44 -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
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.
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#66 2026-04-19 01:15:43 -04:00
Phase 3 PR 67 -- OPC UA Client IReadable + IWritable
dohertj2 created pull request dohertj2/lmxopcua#66 2026-04-19 01:15:39 -04:00
Phase 3 PR 67 -- OPC UA Client IReadable + IWritable
dohertj2 created branch phase-3-pr67-opcua-client-read-write in dohertj2/lmxopcua 2026-04-19 01:15:13 -04:00
dohertj2 pushed to phase-3-pr67-opcua-client-read-write at dohertj2/lmxopcua 2026-04-19 01:15:13 -04:00
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.
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-19 01:10:10 -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
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.
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#65 2026-04-19 01:10:09 -04:00
Phase 3 PR 66 -- OPC UA Client (gateway) driver scaffold
dohertj2 created pull request dohertj2/lmxopcua#65 2026-04-19 01:10:05 -04:00
Phase 3 PR 66 -- OPC UA Client (gateway) driver scaffold
dohertj2 created branch phase-3-pr66-opcua-client-scaffold in dohertj2/lmxopcua 2026-04-19 01:09:37 -04:00
dohertj2 pushed to phase-3-pr66-opcua-client-scaffold at dohertj2/lmxopcua 2026-04-19 01:09:37 -04:00
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.
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-19 00:18:20 -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
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).
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#64 2026-04-19 00:18:19 -04:00
Phase 3 PR 65 -- S7 ITagDiscovery + ISubscribable + IHostConnectivityProbe
dohertj2 created pull request dohertj2/lmxopcua#64 2026-04-19 00:18:15 -04:00
Phase 3 PR 65 -- S7 ITagDiscovery + ISubscribable + IHostConnectivityProbe
dohertj2 created branch phase-3-pr65-s7-discovery-subscribe-probe in dohertj2/lmxopcua 2026-04-19 00:17:47 -04:00
dohertj2 pushed to phase-3-pr65-s7-discovery-subscribe-probe at dohertj2/lmxopcua 2026-04-19 00:17:47 -04:00
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).
dohertj2 pushed to v2 at dohertj2/lmxopcua 2026-04-19 00:13:02 -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
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.
Compare 2 commits »
dohertj2 merged pull request dohertj2/lmxopcua#63 2026-04-19 00:13:01 -04:00
Phase 3 PR 64 -- S7 IReadable + IWritable via S7.Net
dohertj2 created pull request dohertj2/lmxopcua#63 2026-04-19 00:12:57 -04:00
Phase 3 PR 64 -- S7 IReadable + IWritable via S7.Net
dohertj2 created branch phase-3-pr64-s7-read-write in dohertj2/lmxopcua 2026-04-19 00:12:25 -04:00
dohertj2 pushed to phase-3-pr64-s7-read-write at dohertj2/lmxopcua 2026-04-19 00:12:25 -04:00
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.