feat(adminui): author MTConnect write-back on the driver form (Driver.MTConnect-004)

IWritable shipped behind write.enabled and docs/drivers/MTConnect.md told the operator to
set it, but MTConnectDriverForm — the only editor DriverFormMaps maps for this type, with
no raw-JSON escape hatch and no driver-instance API — exposed no write section, so the
documented enablement path was a hand edit of DriverInstance.DriverConfig in the config DB.

- Write-back section: switch + form text stating it is observation injection, that the
  Agent's AllowPut is an independent second gate, and that it does not by itself make a
  node writable over OPC UA (Tag.AccessLevel still composes CurrentWrite).
- Write added to the mirrored MTConnectConfigDto and to the form model, written in BOTH
  states — an omitted block would leave a previously-authored true standing, so the switch
  could never turn writing off again.
- The false 'MTConnect is read-only' header comment is gone; the doc names the new section.

No bUnit here, so the model/serialization seam is unit-tested both sides: the AdminUI test
pins the emitted bytes, and MTConnectFactoryTests parses that same document and asserts
MTConnectDriverOptions.Write.Enabled. Live page verification remains a rig step.
This commit is contained in:
Joseph Doherty
2026-08-10 08:07:36 -04:00
parent 2d7dffcc42
commit 298ad04fba
4 changed files with 182 additions and 4 deletions
+4 -1
View File
@@ -276,7 +276,10 @@ for why Interfaces was rejected as the write-back shape. Design record:
`AllowPutFrom = <host list>` 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
@@ -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 @@
</div>
</section>
@* Write-back *@
<section class="panel rise mt-3" style="animation-delay:.14s">
<div class="panel-head">Write-back</div>
<div style="padding:1rem">
<div class="row g-3">
<div class="col-md-4">
<div class="form-check form-switch">
<InputCheckbox @bind-Value="_form.WriteEnabled" @bind-Value:after="EmitAsync" class="form-check-input" id="mtcWriteEnabled" />
<label class="form-check-label" for="mtcWriteEnabled">Allow write-back</label>
</div>
<div class="form-text mt-0">Default off.</div>
</div>
<div class="col-md-8">
<div class="form-text">
An MTConnect write is <strong>observation injection</strong>: it sets what the Agent
<em>reports</em> for a DataItem, not the machine itself, and the next adapter update overwrites it.
<br />
This is only one of two gates — the Agent's own <span class="mono">AllowPut</span> (its
<span class="mono">agent.cfg</span>) 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
<span class="mono">AccessLevel</span> still composes the <span class="mono">CurrentWrite</span> bit.
CONDITION items are never writable.
</div>
</div>
</div>
</div>
</section>
@if (_form.Validate() is { } error)
{
<div class="panel notice mt-3" style="border-color:var(--alert)">@error</div>
@@ -165,8 +195,8 @@
/// <para>
/// Unknown TOP-LEVEL keys in <paramref name="existingJson"/> are preserved (notably the
/// driver's hand-authored <c>tags[]</c> surface, which this form does not expose and must not
/// silently delete). The <c>probe</c> and <c>reconnect</c> sub-objects are replaced wholesale
/// — the form owns every field in both.
/// silently delete). The <c>probe</c>, <c>reconnect</c> and <c>write</c> sub-objects are
/// replaced wholesale — the form owns every field in all three.
/// </para>
/// <para>
/// 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;
/// <summary>Multiplier applied to the reconnect backoff each retry.</summary>
public double ReconnectBackoffMultiplier { get; set; } = 2.0;
/// <summary>
/// Whether the driver's half of the write-back gate is armed. Default <c>false</c> — the
/// driver shipped read-only, and the Agent's own <c>AllowPut</c> is an independent second
/// gate that this cannot arm.
/// </summary>
public bool WriteEnabled { get; set; }
/// <summary>Loads a form model from a DriverConfig JSON document, falling back to the driver's
/// own defaults for anything absent or unusable.</summary>
@@ -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 },
};
/// <summary>Returns an operator-facing message describing why this config would not start, or
@@ -380,6 +422,8 @@
public MTConnectProbeConfigDto? Probe { get; init; }
/// <summary>Reconnect backoff knobs.</summary>
public MTConnectReconnectConfigDto? Reconnect { get; init; }
/// <summary>Write-back knobs — the driver's half of the two-layer opt-in on observation injection.</summary>
public MTConnectWriteConfigDto? Write { get; init; }
}
/// <summary>Wire mirror of the driver's probe config block.</summary>
@@ -403,4 +447,11 @@
/// <summary>Multiplier applied each retry.</summary>
public double? BackoffMultiplier { get; init; }
}
/// <summary>Wire mirror of the driver's write-back config block.</summary>
public sealed class MTConnectWriteConfigDto
{
/// <summary>Whether the driver accepts write-back at all.</summary>
public bool? Enabled { get; init; }
}
}
@@ -382,6 +382,33 @@ public sealed class MTConnectFactoryTests
.DriverInstanceId.ShouldBe("mt1");
}
/// <summary>
/// Driver.MTConnect-004: the document the AdminUI's <c>MTConnectDriverForm</c> 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 <c>MTConnectDriverFormModelTests</c>, which asserts these exact
/// keys come out of <c>BuildConfigJson</c>.
/// </summary>
[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();
}
/// <summary>
/// A malformed <c>write</c> block fails <b>exactly as its siblings do</b> — the whole
/// document is one <c>JsonSerializer.Deserialize</c>, so a non-boolean <c>enabled</c> faults
@@ -128,6 +128,95 @@ public sealed class MTConnectDriverFormModelTests
o["reconnect"]!["minBackoffMs"]!.GetValue<int>().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<bool>().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<bool>().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<bool>().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);
}
/// <summary>The form's output for a write-enabled instance, mirrored in the driver test suite.</summary>
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