@@ -18,6 +18,20 @@ internal class FakeFocasClient : IFocasClient
|
||||
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-b (issue #269) — separate log of <c>cnc_wrparam</c>-shaped calls
|
||||
/// observed via <see cref="WriteParameterAsync"/>. Tests assert this list to
|
||||
/// verify the driver routed PARAM writes through the typed entry point rather
|
||||
/// than the generic <see cref="WriteAsync"/> dispatch.
|
||||
/// </summary>
|
||||
public List<(FocasAddress addr, FocasDataType type, object? value)> ParameterWriteLog { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-b (issue #269) — separate log of <c>cnc_wrmacro</c>-shaped calls
|
||||
/// observed via <see cref="WriteMacroAsync"/>.
|
||||
/// </summary>
|
||||
public List<(FocasAddress addr, object? value)> MacroWriteLog { get; } = new();
|
||||
|
||||
public virtual Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ConnectCount++;
|
||||
@@ -46,6 +60,37 @@ internal class FakeFocasClient : IFocasClient
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-b (issue #269) — typed parameter-write entry point. Records the
|
||||
/// call in <see cref="ParameterWriteLog"/>, persists the value into
|
||||
/// <see cref="Values"/> at the canonical address (so a subsequent read returns
|
||||
/// the written value), and resolves to <see cref="WriteStatuses"/> if seeded
|
||||
/// (lets a test simulate <c>EW_PASSWD</c> -> <see cref="FocasStatusMapper.BadUserAccessDenied"/>).
|
||||
/// </summary>
|
||||
public virtual Task<uint> WriteParameterAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
ParameterWriteLog.Add((address, type, value));
|
||||
Values[address.Canonical] = value;
|
||||
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan PR F4-b (issue #269) — typed macro-write entry point. See
|
||||
/// <see cref="WriteParameterAsync"/> for the per-canonical-address store / log shape.
|
||||
/// </summary>
|
||||
public virtual Task<uint> WriteMacroAsync(
|
||||
FocasAddress address, object? value, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
MacroWriteLog.Add((address, value));
|
||||
Values[address.Canonical] = value;
|
||||
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public List<(int number, int axis, FocasDataType type)> DiagnosticReads { get; } = new();
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadDiagnosticAsync(
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #269, plan PR F4-b — <c>cnc_wrmacro</c> coverage. The driver-level
|
||||
/// <c>Writes.AllowMacro</c> kill switch sits on top of the F4-a
|
||||
/// <c>Writes.Enabled</c> + per-tag <c>Writable</c> opt-ins. MACRO tags
|
||||
/// surface <see cref="SecurityClassification.Operate"/> (lighter ACL than PARAM)
|
||||
/// because macro writes are the standard HMI-driven recipe / setpoint surface.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasWriteMacroTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasDriver NewDriver(
|
||||
FocasWritesOptions writes,
|
||||
FocasTagDefinition[] tags,
|
||||
out FakeFocasClientFactory factory)
|
||||
{
|
||||
factory = new FakeFocasClientFactory();
|
||||
return new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = writes,
|
||||
}, "drv-1", factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllowMacro_false_returns_BadNotWritable_even_with_Enabled_and_Writable()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowMacro = false, AllowParameter = true },
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("M500", Host, "MACRO:500", FocasDataType.Int32, Writable: true),
|
||||
],
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("M500", 99)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
// Gate fires pre-EnsureConnectedAsync so no wire client gets constructed at all.
|
||||
factory.Clients.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllowMacro_true_dispatches_to_typed_WriteMacroAsync()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowMacro = true },
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("M500", Host, "MACRO:500", FocasDataType.Int32, Writable: true),
|
||||
],
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("M500", 99)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
var log = factory.Clients[0].MacroWriteLog;
|
||||
log.Count.ShouldBe(1);
|
||||
log[0].addr.Kind.ShouldBe(FocasAreaKind.Macro);
|
||||
log[0].addr.Number.ShouldBe(500);
|
||||
log[0].value.ShouldBe(99);
|
||||
// Generic write log untouched — MACRO routes through the typed entry point.
|
||||
factory.Clients[0].WriteLog.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MacroWrite_round_trip_stores_value_visible_to_subsequent_read()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowMacro = true },
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("M500", Host, "MACRO:500", FocasDataType.Int32, Writable: true),
|
||||
],
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("M500", 42)], CancellationToken.None);
|
||||
|
||||
factory.Clients[0].Values["MACRO:500"].ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tag_classification_MACRO_yields_Operate()
|
||||
{
|
||||
// Server-layer ACL key — MACRO: tags require WriteOperate (per the
|
||||
// F4-b plan note about lighter authorization for HMI recipe writes).
|
||||
var tag = new FocasTagDefinition(
|
||||
"M500", Host, "MACRO:500", FocasDataType.Int32, Writable: true);
|
||||
|
||||
FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.Operate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FocasWritesOptions_default_AllowMacro_is_false()
|
||||
{
|
||||
new FocasWritesOptions().AllowMacro.ShouldBeFalse();
|
||||
new FocasDriverOptions().Writes.AllowMacro.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dto_round_trip_preserves_AllowMacro()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"Backend": "unimplemented",
|
||||
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
|
||||
"Tags": [{
|
||||
"Name": "M", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||
"Address": "MACRO:500", "DataType": "Int32", "Writable": true
|
||||
}],
|
||||
"Writes": { "Enabled": true, "AllowMacro": true }
|
||||
}
|
||||
""";
|
||||
|
||||
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
var results = drv.WriteAsync(
|
||||
[new WriteRequest("M", 42)], CancellationToken.None).GetAwaiter().GetResult();
|
||||
// unimplemented backend — anything except BadNotWritable proves the gate let
|
||||
// the call through (likely BadCommunicationError from the no-op factory).
|
||||
results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dto_round_trip_preserves_both_granular_flags()
|
||||
{
|
||||
// Both flags must round-trip independently — a deployment that opts into
|
||||
// PARAM only doesn't accidentally let MACRO writes through, and vice versa.
|
||||
const string json = """
|
||||
{
|
||||
"Backend": "unimplemented",
|
||||
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
|
||||
"Writes": { "Enabled": true, "AllowParameter": true, "AllowMacro": true }
|
||||
}
|
||||
""";
|
||||
|
||||
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Both gates open: the driver no longer rejects either kind at the granular
|
||||
// gate. Submit one of each and confirm neither maps to BadNotWritable
|
||||
// (BadNodeIdUnknown when the tag isn't configured is fine — what matters is
|
||||
// we stayed past the per-kind gate).
|
||||
var results = drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("ParamUnknown", 0),
|
||||
new WriteRequest("MacroUnknown", 0),
|
||||
], CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Both writes hit BadNodeIdUnknown (tag-lookup fails) rather than the
|
||||
// BadNotWritable short-circuit — confirms both flags reached the driver.
|
||||
results.Count.ShouldBe(2);
|
||||
results[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
results[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Per_kind_gate_does_not_affect_PMC_writes()
|
||||
{
|
||||
// Defense in depth: AllowParameter / AllowMacro stay locked but PMC writes
|
||||
// (which already worked in F4-a) keep flowing through Writes.Enabled +
|
||||
// per-tag Writable. This guards against regressing the F4-a surface.
|
||||
var drv = NewDriver(
|
||||
writes: new FocasWritesOptions
|
||||
{
|
||||
Enabled = true,
|
||||
AllowParameter = false,
|
||||
AllowMacro = false,
|
||||
},
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("R100", Host, "R100", FocasDataType.Byte, Writable: true),
|
||||
],
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("R100", (sbyte)1)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
// PMC routes through the generic WriteAsync, not the typed entry points.
|
||||
factory.Clients[0].WriteLog.Count.ShouldBe(1);
|
||||
factory.Clients[0].ParameterWriteLog.ShouldBeEmpty();
|
||||
factory.Clients[0].MacroWriteLog.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #269, plan PR F4-b — <c>cnc_wrparam</c> coverage. The driver-level
|
||||
/// <c>Writes.AllowParameter</c> kill switch sits on top of the F4-a
|
||||
/// <c>Writes.Enabled</c> + per-tag <c>Writable</c> opt-ins; PARAM tags
|
||||
/// additionally surface <see cref="SecurityClassification.Configure"/> for the
|
||||
/// server-layer ACL gate.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasWriteParameterTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static FocasDriver NewDriver(
|
||||
FocasWritesOptions writes,
|
||||
FocasTagDefinition[] tags,
|
||||
out FakeFocasClientFactory factory)
|
||||
{
|
||||
factory = new FakeFocasClientFactory();
|
||||
return new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
Writes = writes,
|
||||
}, "drv-1", factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllowParameter_false_returns_BadNotWritable_even_with_Enabled_and_Writable()
|
||||
{
|
||||
// F4-b — the granular kill switch defaults off so a redeployed driver with
|
||||
// Writes.Enabled=true still keeps PARAM writes locked until the operator team
|
||||
// explicitly opts in. This is the critical defense-in-depth assertion.
|
||||
var drv = NewDriver(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowParameter = false, AllowMacro = true },
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
|
||||
],
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Param", 42)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
// Wire client was never even constructed because the gate short-circuited
|
||||
// before EnsureConnectedAsync — defense in depth + lower blast radius if the
|
||||
// wire client is misconfigured.
|
||||
factory.Clients.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllowParameter_true_dispatches_to_typed_WriteParameterAsync()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowParameter = true },
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
|
||||
],
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Param", 42)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
var log = factory.Clients[0].ParameterWriteLog;
|
||||
log.Count.ShouldBe(1);
|
||||
log[0].addr.Kind.ShouldBe(FocasAreaKind.Parameter);
|
||||
log[0].addr.Number.ShouldBe(1815);
|
||||
log[0].type.ShouldBe(FocasDataType.Int32);
|
||||
log[0].value.ShouldBe(42);
|
||||
// Generic write log is untouched — PARAM goes through the typed entry point.
|
||||
factory.Clients[0].WriteLog.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ParameterWrite_round_trip_stores_value_visible_to_subsequent_read()
|
||||
{
|
||||
var drv = NewDriver(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowParameter = true },
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
|
||||
],
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Param", 42)], CancellationToken.None);
|
||||
|
||||
// Round-trip: the next read sees the written value because the fake stores
|
||||
// by canonical address. This exercises the same shape an integration test
|
||||
// against focas-mock would: write -> read returns the written value.
|
||||
factory.Clients[0].Values["PARAM:1815"].ShouldBe(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EW_PASSWD_from_simulator_maps_to_BadUserAccessDenied()
|
||||
{
|
||||
// F4-b — when the CNC reports EW_PASSWD (parameter-write switch off / unlock
|
||||
// required) the status code surfaces as BadUserAccessDenied rather than
|
||||
// BadNotWritable. F4-d will land the unlock workflow on top of this surface.
|
||||
var drv = NewDriver(
|
||||
writes: new FocasWritesOptions { Enabled = true, AllowParameter = true },
|
||||
tags:
|
||||
[
|
||||
new FocasTagDefinition("Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true),
|
||||
],
|
||||
out var factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
// Seed the fake to mimic EW_PASSWD propagation (mapper assigns
|
||||
// BadUserAccessDenied to that EW_* code post-F4-b).
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.WriteStatuses["PARAM:1815"] = FocasStatusMapper.BadUserAccessDenied;
|
||||
return c;
|
||||
};
|
||||
|
||||
// Re-init now that the customiser is in place.
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Param", 42)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StatusMapper_EW_PASSWD_returns_BadUserAccessDenied()
|
||||
{
|
||||
// EW_PASSWD == 11 per FANUC FOCAS docs. Pre-F4-b this mapped to BadNotWritable;
|
||||
// F4-b remaps it so clients can branch on user-vs-driver write rejection.
|
||||
FocasStatusMapper.MapFocasReturn(11).ShouldBe(FocasStatusMapper.BadUserAccessDenied);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tag_classification_PARAM_yields_Configure()
|
||||
{
|
||||
// Server-layer ACL key — PARAM: tags require WriteConfigure group membership
|
||||
// (vs MACRO: tags which require WriteOperate). Per the
|
||||
// feedback_acl_at_server_layer memory the driver only declares classification;
|
||||
// DriverNodeManager applies the gate.
|
||||
var tag = new FocasTagDefinition(
|
||||
"Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: true);
|
||||
|
||||
FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.Configure);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tag_classification_PARAM_non_writable_yields_ViewOnly()
|
||||
{
|
||||
// ViewOnly trumps the kind-based classification when the tag isn't writable.
|
||||
var tag = new FocasTagDefinition(
|
||||
"Param", Host, "PARAM:1815", FocasDataType.Int32, Writable: false);
|
||||
|
||||
FocasDriver.ClassifyTag(tag).ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FocasWritesOptions_default_AllowParameter_is_false()
|
||||
{
|
||||
// Defense in depth: a fresh FocasWritesOptions has both granular kill
|
||||
// switches off so a config-DB row that omits AllowParameter doesn't
|
||||
// silently flip parameter writes on.
|
||||
new FocasWritesOptions().AllowParameter.ShouldBeFalse();
|
||||
new FocasDriverOptions().Writes.AllowParameter.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dto_round_trip_preserves_AllowParameter()
|
||||
{
|
||||
// JSON config -> FocasDriverOptions; the Writes.AllowParameter flag must
|
||||
// survive the bootstrapper's deserialize step. Use a known-empty Tags config
|
||||
// and a sentinel write to verify the gate reached the driver.
|
||||
const string jsonAllowed = """
|
||||
{
|
||||
"Backend": "unimplemented",
|
||||
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
|
||||
"Tags": [{
|
||||
"Name": "P", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||
"Address": "PARAM:1815", "DataType": "Int32", "Writable": true
|
||||
}],
|
||||
"Writes": { "Enabled": true, "AllowParameter": true }
|
||||
}
|
||||
""";
|
||||
|
||||
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", jsonAllowed);
|
||||
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
// Real wire client is the unimplemented one — Connect throws, and the driver
|
||||
// surfaces that as BadCommunicationError. The key is that the result is NOT
|
||||
// BadNotWritable: that proves the AllowParameter gate didn't short-circuit.
|
||||
var results = drv.WriteAsync(
|
||||
[new WriteRequest("P", 42)], CancellationToken.None).GetAwaiter().GetResult();
|
||||
results.Single().StatusCode.ShouldNotBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dto_default_omitted_AllowParameter_keeps_safer_default()
|
||||
{
|
||||
// A Writes section with just { Enabled: true } must NOT silently flip the
|
||||
// granular kill switches on. PARAM writes should still get BadNotWritable.
|
||||
const string json = """
|
||||
{
|
||||
"Backend": "unimplemented",
|
||||
"Devices": [{ "HostAddress": "focas://10.0.0.5:8193" }],
|
||||
"Tags": [{
|
||||
"Name": "P", "DeviceHostAddress": "focas://10.0.0.5:8193",
|
||||
"Address": "PARAM:1815", "DataType": "Int32", "Writable": true
|
||||
}],
|
||||
"Writes": { "Enabled": true }
|
||||
}
|
||||
""";
|
||||
|
||||
var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||
drv.InitializeAsync("{}", CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
var results = drv.WriteAsync(
|
||||
[new WriteRequest("P", 42)], CancellationToken.None).GetAwaiter().GetResult();
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user