diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md index 489448ed..c9479c1d 100644 --- a/docs/drivers/MTConnect.md +++ b/docs/drivers/MTConnect.md @@ -276,7 +276,10 @@ for why Interfaces was rejected as the write-back shape. Design record: `AllowPutFrom = ` so only named hosts may write; that list is the Agent's own access control and this driver neither reads nor influences it. 2. **The driver** — `"Write": { "Enabled": true }` in the driver config (default - `false`; an absent block is disabled). + `false`; an absent block is disabled). Authored in the AdminUI on the `/raw` driver + config modal's **Write-back** section (`MTConnectDriverForm`), which writes the block + explicitly in both states — so turning the switch back off clears a previously + authored `true` rather than leaving it standing. Either gate alone leaves the shipped read-only behaviour unchanged: with `Write.Enabled` off every slot is refused `BadNotWritable` without an Agent being touched, and with diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor index 1bbd9674..5da6e6c6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor @@ -3,7 +3,9 @@ paths (/probe, /current, /sample) to it and optionally narrows to one DeviceName, so unlike Modbus/S7 there is no host/port to split onto the device. - MTConnect is read-only — there are no write knobs to author. *@ + Write-back is authored here too (Driver.MTConnect-004): the driver gained IWritable behind + write.enabled, and until this section existed the documented enablement path was a hand edit of + DriverInstance.DriverConfig in the config DB. *@ @using System.Text.Json @using System.Text.Json.Nodes @using System.Text.Json.Serialization @@ -109,6 +111,34 @@ +@* Write-back *@ +
+
Write-back
+
+
+
+
+ + +
+
Default off.
+
+
+
+ An MTConnect write is observation injection: it sets what the Agent + reports for a DataItem, not the machine itself, and the next adapter update overwrites it. +
+ This is only one of two gates — the Agent's own AllowPut (its + agent.cfg) must be on as well, and neither gate can arm the other. + Turning this on does not by itself make any node writable over OPC UA: the tag's + AccessLevel still composes the CurrentWrite bit. + CONDITION items are never writable. +
+
+
+
+
+ @if (_form.Validate() is { } error) {
@error
@@ -165,8 +195,8 @@ /// /// Unknown TOP-LEVEL keys in are preserved (notably the /// driver's hand-authored tags[] surface, which this form does not expose and must not - /// silently delete). The probe and reconnect sub-objects are replaced wholesale - /// — the form owns every field in both. + /// silently delete). The probe, reconnect and write sub-objects are + /// replaced wholesale — the form owns every field in all three. /// /// /// A key whose form value is unusable (blank URI, non-positive timing knob) is REMOVED rather @@ -268,6 +298,12 @@ public int ReconnectMaxBackoffMs { get; set; } = 30_000; /// Multiplier applied to the reconnect backoff each retry. public double ReconnectBackoffMultiplier { get; set; } = 2.0; + /// + /// Whether the driver's half of the write-back gate is armed. Default false — the + /// driver shipped read-only, and the Agent's own AllowPut is an independent second + /// gate that this cannot arm. + /// + public bool WriteEnabled { get; set; } /// Loads a form model from a DriverConfig JSON document, falling back to the driver's /// own defaults for anything absent or unusable. @@ -293,6 +329,7 @@ ReconnectMinBackoffMs = dto.Reconnect?.MinBackoffMs ?? defaults.ReconnectMinBackoffMs, ReconnectMaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? defaults.ReconnectMaxBackoffMs, ReconnectBackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? defaults.ReconnectBackoffMultiplier, + WriteEnabled = dto.Write?.Enabled ?? defaults.WriteEnabled, }; } @@ -319,6 +356,11 @@ MaxBackoffMs = Positive(ReconnectMaxBackoffMs), BackoffMultiplier = ReconnectBackoffMultiplier > 0 ? ReconnectBackoffMultiplier : null, }, + + // Written even when false, unlike the timing knobs: those are omitted so the driver + // substitutes its own positive default, whereas an omitted write block would leave a + // previously-authored "enabled": true standing and the switch could never turn it off. + Write = new MTConnectWriteConfigDto { Enabled = WriteEnabled }, }; /// Returns an operator-facing message describing why this config would not start, or @@ -380,6 +422,8 @@ public MTConnectProbeConfigDto? Probe { get; init; } /// Reconnect backoff knobs. public MTConnectReconnectConfigDto? Reconnect { get; init; } + /// Write-back knobs — the driver's half of the two-layer opt-in on observation injection. + public MTConnectWriteConfigDto? Write { get; init; } } /// Wire mirror of the driver's probe config block. @@ -403,4 +447,11 @@ /// Multiplier applied each retry. public double? BackoffMultiplier { get; init; } } + + /// Wire mirror of the driver's write-back config block. + public sealed class MTConnectWriteConfigDto + { + /// Whether the driver accepts write-back at all. + public bool? Enabled { get; init; } + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs index 0f7ee5c7..97b5ab5b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs @@ -382,6 +382,33 @@ public sealed class MTConnectFactoryTests .DriverInstanceId.ShouldBe("mt1"); } + /// + /// Driver.MTConnect-004: the document the AdminUI's MTConnectDriverForm emits arms + /// write-back through this parse. The literal below is the form's own output for a + /// write-enabled instance — key spelling, casing and block nesting included — since the + /// AdminUI cannot be referenced from a driver test project; the form side of the same + /// contract is pinned by MTConnectDriverFormModelTests, which asserts these exact + /// keys come out of BuildConfigJson. + /// + [Fact] + public void The_document_the_AdminUI_form_emits_arms_write_back() + { + const string formEmitted = + """{"agentUri":"http://agent:5000","requestTimeoutMs":5000,"sampleIntervalMs":1000,"sampleCount":1000,"heartbeatMs":10000,"probe":{"enabled":true,"intervalMs":5000,"timeoutMs":2000},"reconnect":{"minBackoffMs":0,"maxBackoffMs":30000,"backoffMultiplier":2},"write":{"enabled":true}}"""; + + var options = MTConnectDriver.ParseOptions("mt1", formEmitted); + + options.Write.Enabled.ShouldBeTrue(); + options.AgentUri.ShouldBe("http://agent:5000"); + + // The same document with the switch off is read-only again — the form writes the block in + // both states, so this is the round trip an operator turning the feature back off performs. + MTConnectDriver.ParseOptions( + "mt1", + formEmitted.Replace("""enabled":true}""", """enabled":false}""", StringComparison.Ordinal)) + .Write.Enabled.ShouldBeFalse(); + } + /// /// A malformed write block fails exactly as its siblings do — the whole /// document is one JsonSerializer.Deserialize, so a non-boolean enabled faults diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs index b291a90b..0976b3af 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs @@ -128,6 +128,95 @@ public sealed class MTConnectDriverFormModelTests o["reconnect"]!["minBackoffMs"]!.GetValue().ShouldBe(0); } + // ---- write-back (Driver.MTConnect-004) ----------------------------------------------------- + + [Fact] + public void Write_back_is_off_by_default_and_is_written_explicitly() + { + // The capability shipped 2026-08-05 behind write.enabled and docs/drivers/MTConnect.md tells + // the operator to set it — but the form authored no write block at all, so the documented + // enablement path existed only as a hand edit of DriverInstance.DriverConfig in the config DB. + // + // The default is written rather than omitted: the driver reads an absent block as disabled + // either way, and an explicit "enabled": false is what makes a config an operator opened and + // saved say what it does. It is also what lets the form TURN THE FLAG OFF again — an omitted + // key would leave a previously-authored true standing. + var o = Emit(new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" }); + + o["write"]!["enabled"]!.GetValue().ShouldBeFalse(); + } + + [Fact] + public void Write_back_enabled_reaches_the_key_the_driver_binds() + { + var o = Emit(new MTConnectDriverForm.MTConnectFormModel + { + AgentUri = "http://agent:5000", + WriteEnabled = true, + }); + + // MTConnectDriver.ParseOptions binds write.enabled into MTConnectDriverOptions.Write.Enabled + // (pinned driver-side by MTConnectFactoryTests). This is the AdminUI half of that contract. + o["write"]!["enabled"]!.GetValue().ShouldBeTrue(); + } + + [Fact] + public void Write_back_round_trips_and_a_hand_authored_block_loads_into_the_form() + { + // The mitigating property the finding recorded — BuildConfigJson preserves unknown TOP-LEVEL + // keys, so a hand-authored write block survived an edit through the form — is not enough on + // its own: unless the form READS it, opening the modal shows the switch off over a config + // that has writing on, and the operator's next save is the one that finally clears it. + const string handAuthored = """{"agentUri":"http://agent:5000","write":{"enabled":true}}"""; + + var form = MTConnectDriverForm.MTConnectFormModel.FromJson(handAuthored); + form.WriteEnabled.ShouldBeTrue(); + + var again = MTConnectDriverForm.MTConnectFormModel.FromJson( + MTConnectDriverForm.BuildConfigJson(form, handAuthored)); + again.WriteEnabled.ShouldBeTrue(); + } + + [Fact] + public void Turning_write_back_off_clears_a_previously_authored_true() + { + // The falsification control for the "always write the block" decision above: a form that + // omitted the key when false would leave the stored true in place, so the switch would appear + // to turn writing off while the deployed driver went on accepting writes. + const string existing = """{"agentUri":"http://agent:5000","write":{"enabled":true}}"""; + + var form = MTConnectDriverForm.MTConnectFormModel.FromJson(existing); + form.WriteEnabled = false; + + Emit(form, existing)["write"]!["enabled"]!.GetValue().ShouldBeFalse(); + } + + [Fact] + public void FromJson_reads_a_pascal_case_write_block() + { + var form = MTConnectDriverForm.MTConnectFormModel.FromJson( + """{"AgentUri":"http://agent:5000","Write":{"Enabled":true}}"""); + + form.WriteEnabled.ShouldBeTrue(); + } + + [Fact] + public void The_write_enabled_document_is_the_literal_the_driver_test_parses() + { + // The AdminUI cannot be referenced from a driver test project, so the driver-side leg of this + // contract (MTConnectFactoryTests.The_document_the_AdminUI_form_emits_arms_write_back) parses a + // literal copy of this form's output. Asserting the bytes here is what stops the two drifting: + // a rename or a reordering on this side fails HERE, naming the literal that must be updated. + MTConnectDriverForm.BuildConfigJson( + new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000", WriteEnabled = true }, + null) + .ShouldBe(WriteEnabledDocument); + } + + /// The form's output for a write-enabled instance, mirrored in the driver test suite. + private const string WriteEnabledDocument = + """{"agentUri":"http://agent:5000","requestTimeoutMs":5000,"sampleIntervalMs":1000,"sampleCount":1000,"heartbeatMs":10000,"probe":{"enabled":true,"intervalMs":5000,"timeoutMs":2000},"reconnect":{"minBackoffMs":0,"maxBackoffMs":30000,"backoffMultiplier":2},"write":{"enabled":true}}"""; + // ---- Validate ------------------------------------------------------------------------------ [Fact] @@ -292,6 +381,8 @@ public sealed class MTConnectDriverFormModelTests dto.Reconnect!.MinBackoffMs.ShouldBe(0); dto.Reconnect.MaxBackoffMs.ShouldBe(30000); dto.Reconnect.BackoffMultiplier.ShouldBe(2.0); + dto.Write.ShouldNotBeNull(); + dto.Write!.Enabled.ShouldBe(false); } private sealed class WireDto @@ -304,6 +395,12 @@ public sealed class MTConnectDriverFormModelTests public int? HeartbeatMs { get; init; } public WireProbe? Probe { get; init; } public WireReconnect? Reconnect { get; init; } + public WireWrite? Write { get; init; } + } + + private sealed class WireWrite + { + public bool? Enabled { get; init; } } private sealed class WireProbe