Wire ApplyScaling, StorageRate; close out write-commands plan
ApplyScaling (HistorianTagDefinition.ApplyScaling): The EnsT2 trailer's second byte controls server-side scaling — `FE 00` mirrors MinRaw to MinEU and sets AnalogTag.Scaling=0; `FE 01` persists distinct MinRaw/MaxRaw and sets Scaling=1. Decoded by toggling set_ApplyScaling on the native harness and capturing the wire bytes for both values with identical inputs. The earlier docs claimed EnsureTagAsync needed a follow-up "UpdateTags" call; the WCF surface has no such operation — toggling that one byte is the whole fix. StorageRate (HistorianTagDefinition.StorageRateMs): Serializer accepts a non-default rate, validated empirically against the live server which only accepts quantized values (1000/5000/10000/60000/300000 ms). EnsureTagAsync upsert semantics: Second call on the same tag name with different fields succeeds and updates Description, MinEU, MaxEU, MinRaw, MaxRaw, Scaling in place (verified by direct SQL inspection in a live test). Plan + doc closeout: write-commands-reverse-engineering.md rewritten as a current-state plan with three workstreams (A doc closeout / B idempotency / C1 StorageRate) and a parallelism table; prior phase notes preserved as appendix. handoff.md, implementation-status.md, wcf-contract-evidence.md, README.md updated to remove "writes are out of scope" / non-existent UpdateTags references and document the actual EnsT2 wire format including the `FE xx` trailer. Reverse-engineering harness gains --write-apply-scaling and a SQL post-check that prints the persisted AnalogTag bounds so future RE sessions can verify wire→DB causality without leaving the harness. 169/169 tests pass (was 165; +4 new tests covering ApplyScaling, StorageRate golden bytes, StorageRate live persistence, and EnsureTagAsync upsert semantics). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -488,6 +488,179 @@ public sealed class HistorianClientIntegrationTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTagAsync_NonDefaultStorageRate_PersistsToTagTable()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string sandboxTag = "RetestSdkWriteStorageRateRT";
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
bool ok = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = "SDK StorageRate round-trip",
|
||||
EngineeringUnit = "test",
|
||||
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
|
||||
// Server only accepts quantized rates — 1000, 5000, 10000, 60000, 300000 ms.
|
||||
StorageRateMs = 5000u,
|
||||
}, CancellationToken.None);
|
||||
Assert.True(ok, "EnsureTagAsync returned false");
|
||||
|
||||
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
|
||||
sql.Open();
|
||||
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
|
||||
cmd.CommandText = "SELECT StorageRate FROM Tag WHERE TagName = @t";
|
||||
cmd.Parameters.AddWithValue("@t", sandboxTag);
|
||||
object? rate = cmd.ExecuteScalar();
|
||||
Assert.NotNull(rate);
|
||||
Assert.Equal(5000, Convert.ToInt32(rate));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTagAsync_CalledTwiceOnSameTag_UpdatesFieldsInPlace()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string sandboxTag = "RetestSdkWriteIdempotencyRT";
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
bool firstOk = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = "First version",
|
||||
EngineeringUnit = "test",
|
||||
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
|
||||
MinEU = 0.0, MaxEU = 100.0, MinRaw = 0.0, MaxRaw = 100.0,
|
||||
ApplyScaling = false,
|
||||
}, CancellationToken.None);
|
||||
Assert.True(firstOk, "First EnsureTagAsync returned false");
|
||||
(string desc1, double minEU1, double maxEU1, double minRaw1, double maxRaw1, int scaling1) = ReadTagState(sandboxTag);
|
||||
Assert.Equal("First version", desc1);
|
||||
Assert.Equal(0.0, minEU1);
|
||||
Assert.Equal(0, scaling1);
|
||||
|
||||
bool secondOk = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = "Second version",
|
||||
EngineeringUnit = "kPa",
|
||||
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
|
||||
MinEU = -50.0, MaxEU = 200.0, MinRaw = 10.0, MaxRaw = 4095.0,
|
||||
ApplyScaling = true,
|
||||
}, CancellationToken.None);
|
||||
Assert.True(secondOk, "Second EnsureTagAsync returned false");
|
||||
(string desc2, double minEU2, double maxEU2, double minRaw2, double maxRaw2, int scaling2) = ReadTagState(sandboxTag);
|
||||
|
||||
// EnsureTagAsync upserts: second call updates the existing row in place.
|
||||
Assert.Equal("Second version", desc2);
|
||||
Assert.Equal(-50.0, minEU2);
|
||||
Assert.Equal(200.0, maxEU2);
|
||||
Assert.Equal(10.0, minRaw2);
|
||||
Assert.Equal(4095.0, maxRaw2);
|
||||
Assert.Equal(1, scaling2);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
|
||||
}
|
||||
|
||||
static (string desc, double minEU, double maxEU, double minRaw, double maxRaw, int scaling) ReadTagState(string tagName)
|
||||
{
|
||||
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
|
||||
sql.Open();
|
||||
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
|
||||
cmd.CommandText = "SELECT t.[Description], a.MinEU, a.MaxEU, a.MinRaw, a.MaxRaw, a.Scaling FROM Tag t JOIN AnalogTag a ON a.TagName=t.TagName WHERE t.TagName=@t";
|
||||
cmd.Parameters.AddWithValue("@t", tagName);
|
||||
using Microsoft.Data.SqlClient.SqlDataReader r = cmd.ExecuteReader();
|
||||
Assert.True(r.Read(), $"Tag {tagName} not found");
|
||||
return (r.GetString(0), r.GetDouble(1), r.GetDouble(2), r.GetDouble(3), r.GetDouble(4), Convert.ToInt32(r.GetValue(5)));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureTagAsync_ApplyScalingTrue_PersistsDistinctMinRawAndMaxRaw()
|
||||
{
|
||||
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
|
||||
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const string sandboxTag = "RetestSdkWriteApplyScalingRT";
|
||||
HistorianClient client = new(new HistorianClientOptions
|
||||
{
|
||||
Host = host,
|
||||
IntegratedSecurity = true,
|
||||
Transport = HistorianTransport.LocalPipe,
|
||||
});
|
||||
|
||||
AVEVA.Historian.Client.Models.HistorianTagDefinition definition = new()
|
||||
{
|
||||
TagName = sandboxTag,
|
||||
Description = "SDK ApplyScaling round-trip",
|
||||
EngineeringUnit = "test",
|
||||
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
|
||||
MinEU = -50.0,
|
||||
MaxEU = 200.0,
|
||||
MinRaw = 10.0,
|
||||
MaxRaw = 4095.0,
|
||||
ApplyScaling = true,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
bool ensured = await client.EnsureTagAsync(definition, CancellationToken.None);
|
||||
Assert.True(ensured, "EnsureTagAsync(ApplyScaling=true) returned false against the live Historian.");
|
||||
|
||||
// Verify directly against the AnalogTag table — the read-path GetTagMetadataAsync
|
||||
// surfaces only one of (MinRaw, MinEU); SQL is the unambiguous source of truth.
|
||||
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
|
||||
sql.Open();
|
||||
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
|
||||
cmd.CommandText = "SELECT MinEU, MaxEU, MinRaw, MaxRaw, Scaling FROM AnalogTag WHERE TagName = @t";
|
||||
cmd.Parameters.AddWithValue("@t", sandboxTag);
|
||||
using Microsoft.Data.SqlClient.SqlDataReader r = cmd.ExecuteReader();
|
||||
Assert.True(r.Read(), $"AnalogTag row for {sandboxTag} not found after EnsureTag.");
|
||||
Assert.Equal(-50.0, r.GetDouble(0));
|
||||
Assert.Equal(200.0, r.GetDouble(1));
|
||||
Assert.Equal(10.0, r.GetDouble(2));
|
||||
Assert.Equal(4095.0, r.GetDouble(3));
|
||||
Assert.Equal(1, Convert.ToInt32(r.GetValue(4)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTagMetadataAsync_PopulatesDescriptionAndEuRangeForAnalogTag()
|
||||
{
|
||||
|
||||
@@ -8,16 +8,9 @@ public sealed class HistorianTagWriteProtocolTests
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_MatchesCapturedNativeBytesByteForByte()
|
||||
{
|
||||
// Reproduces the captured native EnsT2(Float) CTagMetadata bytes from
|
||||
// artifacts/reverse-engineering/instrumented-wcf-writemessage-writes/
|
||||
// fresh-enst2-latest.ndjson — 144 bytes. Inputs:
|
||||
// tagName = "RetestSdkWriteSandbox" (the sandbox)
|
||||
// description = "SDK write-RE sandbox tag"
|
||||
// eu = "test"
|
||||
// FILETIME = 0x01DCDBBFCD87D049 (captured at run time)
|
||||
// The earlier 146-byte version mistakenly included the WCF EndElement closing
|
||||
// markers (`01 01 01`) and was missing the 0x4E leading marker — both have been
|
||||
// corrected by walking the native InBuff field-by-field.
|
||||
// Reproduces the captured native EnsT2(Float) CTagMetadata bytes for the sandbox
|
||||
// tag with default ranges and ApplyScaling=false. 2-byte trailer = `FE 00` where
|
||||
// the second byte is the ApplyScaling flag (0x00 = false; 0x01 = true).
|
||||
const string ExpectedHex =
|
||||
"4E6703000100000004C6020100000000000000000000000000000000"
|
||||
+ "09150052657465737453646B577269746553616E64626F78"
|
||||
@@ -118,6 +111,82 @@ public sealed class HistorianTagWriteProtocolTests
|
||||
Assert.Equal(Convert.ToHexString(expected), Convert.ToHexString(actual));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_NonDefaultStorageRate_EncodesUInt32LittleEndianAtKnownOffset()
|
||||
{
|
||||
byte[] defaultRate = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteRate",
|
||||
description: "SDK write-RE sandbox tag",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL));
|
||||
byte[] customRate = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteRate",
|
||||
description: "SDK write-RE sandbox tag",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
|
||||
storageRateMs: 2500u);
|
||||
|
||||
Assert.Equal(defaultRate.Length, customRate.Length);
|
||||
// Storage-rate uint32 is at the byte position immediately after the
|
||||
// "MDAS" + flag-block sequence; the only diff between the two payloads
|
||||
// is those 4 bytes.
|
||||
int firstDiff = 0;
|
||||
while (firstDiff < defaultRate.Length && defaultRate[firstDiff] == customRate[firstDiff]) firstDiff++;
|
||||
Assert.Equal(0xE8, defaultRate[firstDiff]); // 1000 = 0x000003E8 LE → 0xE8 0x03 0x00 0x00
|
||||
Assert.Equal(0x03, defaultRate[firstDiff + 1]);
|
||||
Assert.Equal(0xC4, customRate[firstDiff]); // 2500 = 0x000009C4 LE → 0xC4 0x09 0x00 0x00
|
||||
Assert.Equal(0x09, customRate[firstDiff + 1]);
|
||||
// Beyond the 4-byte rate field, the rest is identical.
|
||||
Assert.Equal(
|
||||
Convert.ToHexString(defaultRate.AsSpan(firstDiff + 4)),
|
||||
Convert.ToHexString(customRate.AsSpan(firstDiff + 4)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_ZeroStorageRate_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentOutOfRangeException>(() => HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteRate",
|
||||
description: "x",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.UtcNow,
|
||||
storageRateMs: 0u));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeAnalogCTagMetadata_ApplyScalingTrue_FlipsTrailerSecondByte()
|
||||
{
|
||||
// Captured 2026-05-04 by toggling --write-apply-scaling on the native harness:
|
||||
// ApplyScaling=true sets the trailer's second byte to 0x01 (vs 0x00 for false).
|
||||
// Live-verified: with 0x01 the server persists distinct MinRaw/MaxRaw and sets
|
||||
// AnalogTag.Scaling=1; with 0x00 it mirrors MinRaw to MinEU and sets Scaling=0.
|
||||
byte[] withFlag = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteFloatRanges",
|
||||
description: "SDK write-RE sandbox tag",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
|
||||
dataType: AVEVA.Historian.Client.Models.HistorianDataType.Float,
|
||||
minEU: -50.0, maxEU: 200.0, minRaw: 10.0, maxRaw: 4095.0,
|
||||
applyScaling: true);
|
||||
byte[] withoutFlag = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
|
||||
tagName: "RetestSdkWriteFloatRanges",
|
||||
description: "SDK write-RE sandbox tag",
|
||||
engineeringUnit: "test",
|
||||
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdbed474b29beL),
|
||||
dataType: AVEVA.Historian.Client.Models.HistorianDataType.Float,
|
||||
minEU: -50.0, maxEU: 200.0, minRaw: 10.0, maxRaw: 4095.0,
|
||||
applyScaling: false);
|
||||
|
||||
Assert.Equal(withoutFlag.Length, withFlag.Length);
|
||||
Assert.Equal(0xFE, withFlag[^2]);
|
||||
Assert.Equal(0x01, withFlag[^1]);
|
||||
Assert.Equal(0xFE, withoutFlag[^2]);
|
||||
Assert.Equal(0x00, withoutFlag[^1]);
|
||||
Assert.Equal(
|
||||
Convert.ToHexString(withoutFlag.AsSpan(0, withoutFlag.Length - 1)),
|
||||
Convert.ToHexString(withFlag.AsSpan(0, withFlag.Length - 1)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetAnalogDataTypeCode_UnsupportedType_Throws()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user