fix(write): re-gate UInt1 — historian creates degenerate UInt1 analog tags
Live evidence: EnsureTags(UInt1) returns success but the server stores a degenerate tag (descriptor type byte 0x00, no GUID/name), so GetTagInfo truncates and writes fail. Not client-fixable on the analog path. Int8/UInt8 stay GREEN (live-proven). UInt1 reverts to ProtocolEvidenceMissingException (fail-closed); golden rows removed; negative-gate tests added. Removed the one-off capture diagnostic. Claude-Session: https://claude.ai/code/session_012SDSQ3AcaXqPcBtDESBRii
This commit is contained in:
@@ -32,8 +32,9 @@ namespace AVEVA.Historian.Client.Wcf;
|
|||||||
/// Int8 → Int64(8) · UInt8 → UInt64(8)
|
/// Int8 → Int64(8) · UInt8 → UInt64(8)
|
||||||
/// </code>
|
/// </code>
|
||||||
///
|
///
|
||||||
/// Live-captured for Float/Double/Int2/Int4/UInt4; UInt1/Int8/UInt8 mirror the captured
|
/// Live-captured for Float/Double/Int2/Int4/UInt4; Int8/UInt8 mirror the captured
|
||||||
/// Double value layout (8 LE bytes for Int8/UInt8, 1 byte for UInt1) pending live round-trip.
|
/// Double value layout (8 LE bytes) pending live round-trip. UInt1 is re-gated: the
|
||||||
|
/// historian accepts EnsureTags(UInt1) but stores a degenerate tag — writes would fail.
|
||||||
/// Other tag types have no captured value encoding and are rejected.
|
/// Other tag types have no captured value encoding and are rejected.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static class HistorianHistoricalWriteProtocol
|
internal static class HistorianHistoricalWriteProtocol
|
||||||
@@ -54,7 +55,8 @@ internal static class HistorianHistoricalWriteProtocol
|
|||||||
/// <paramref name="tagGuid"/> is the per-tag GUID (from the gRPC tag-info read),
|
/// <paramref name="tagGuid"/> is the per-tag GUID (from the gRPC tag-info read),
|
||||||
/// <paramref name="dataType"/> is the tag's declared analog type (selects the value width), and
|
/// <paramref name="dataType"/> is the tag's declared analog type (selects the value width), and
|
||||||
/// <paramref name="receivedTimeUtc"/> is the storage/received timestamp the orchestrator stamps.
|
/// <paramref name="receivedTimeUtc"/> is the storage/received timestamp the orchestrator stamps.
|
||||||
/// Throws <see cref="ProtocolEvidenceMissingException"/> for tag types without a captured encoding.
|
/// Throws <see cref="ProtocolEvidenceMissingException"/> for tag types without a captured encoding
|
||||||
|
/// (including UInt1, which is re-gated — the historian creates a degenerate UInt1 analog tag).
|
||||||
/// Int8/UInt8 carry exact magnitude only up to 2^53 — the value API is <see langword="double"/>;
|
/// Int8/UInt8 carry exact magnitude only up to 2^53 — the value API is <see langword="double"/>;
|
||||||
/// full 64-bit range is a separate follow-on.
|
/// full 64-bit range is a separate follow-on.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -121,10 +123,6 @@ internal static class HistorianHistoricalWriteProtocol
|
|||||||
BinaryPrimitives.WriteUInt32LittleEndian(b, checked((uint)value));
|
BinaryPrimitives.WriteUInt32LittleEndian(b, checked((uint)value));
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
case HistorianDataType.UInt1:
|
|
||||||
{
|
|
||||||
return [checked((byte)value)];
|
|
||||||
}
|
|
||||||
case HistorianDataType.Int8:
|
case HistorianDataType.Int8:
|
||||||
{
|
{
|
||||||
byte[] b = new byte[8];
|
byte[] b = new byte[8];
|
||||||
@@ -140,7 +138,7 @@ internal static class HistorianHistoricalWriteProtocol
|
|||||||
default:
|
default:
|
||||||
throw new ProtocolEvidenceMissingException(
|
throw new ProtocolEvidenceMissingException(
|
||||||
$"AddHistoricalValuesAsync has no captured value encoding for tag data type '{dataType}'. " +
|
$"AddHistoricalValuesAsync has no captured value encoding for tag data type '{dataType}'. " +
|
||||||
"Captured types: Float, Double, Int2, Int4, UInt4, UInt1, Int8, UInt8.");
|
"Captured types: Float, Double, Int2, Int4, UInt4, Int8, UInt8.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,11 @@ internal static class HistorianTagWriteProtocol
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Native CDataType wire codes per data type — captured by probing every type via
|
/// Native CDataType wire codes per data type — captured by probing every type via
|
||||||
/// instrument-wcf-writemessage. Matches the codes already documented in
|
/// instrument-wcf-writemessage. Matches the codes already documented in
|
||||||
/// <see cref="HistorianWcfTagClient"/> MapDataType for the read path; UInt1/Int8/UInt8
|
/// <see cref="HistorianWcfTagClient"/> MapDataType for the read path; Int8/UInt8
|
||||||
/// reuse the same read-side codes (0x08/0x19/0x39).
|
/// reuse the same read-side codes (0x19/0x39).
|
||||||
|
/// UInt1 is excluded: live evidence shows the historian accepts EnsureTags(UInt1)
|
||||||
|
/// with success but stores a degenerate tag (null type descriptor) that subsequent
|
||||||
|
/// GetTagInfo cannot parse — not client-fixable on the analog path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static byte GetAnalogDataTypeCode(Models.HistorianDataType dataType) => dataType switch
|
public static byte GetAnalogDataTypeCode(Models.HistorianDataType dataType) => dataType switch
|
||||||
{
|
{
|
||||||
@@ -71,11 +74,10 @@ internal static class HistorianTagWriteProtocol
|
|||||||
Models.HistorianDataType.UInt4 => 0x11,
|
Models.HistorianDataType.UInt4 => 0x11,
|
||||||
Models.HistorianDataType.Int2 => 0x29,
|
Models.HistorianDataType.Int2 => 0x29,
|
||||||
Models.HistorianDataType.Int4 => 0x31,
|
Models.HistorianDataType.Int4 => 0x31,
|
||||||
Models.HistorianDataType.UInt1 => 0x08,
|
|
||||||
Models.HistorianDataType.Int8 => 0x19,
|
Models.HistorianDataType.Int8 => 0x19,
|
||||||
Models.HistorianDataType.UInt8 => 0x39,
|
Models.HistorianDataType.UInt8 => 0x39,
|
||||||
_ => throw new ProtocolEvidenceMissingException(
|
_ => throw new ProtocolEvidenceMissingException(
|
||||||
$"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4, UInt1, Int8, UInt8."),
|
$"EnsureTagAsync data type {dataType} has no captured CTagMetadata wire code; supported: Float, Double, UInt2, UInt4, Int2, Int4, Int8, UInt8."),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly byte[] AnalogPadding16 = new byte[16];
|
private static readonly byte[] AnalogPadding16 = new byte[16];
|
||||||
@@ -122,9 +124,10 @@ internal static class HistorianTagWriteProtocol
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float,
|
/// Serializes a CTagMetadata payload for an analog tag. Live-verified for Float,
|
||||||
/// Double, Int2, Int4, UInt4; UInt1/Int8/UInt8 supported via the read-side type
|
/// Double, Int2, Int4, UInt4; Int8/UInt8 supported via the read-side type codes
|
||||||
/// codes (pending live round-trip) — see <see cref="GetAnalogDataTypeCode"/> for the
|
/// (pending live round-trip); UInt1 re-gated (historian creates a degenerate tag) —
|
||||||
/// type-code mapping. Output matches the byte-for-byte capture for the same inputs.
|
/// see <see cref="GetAnalogDataTypeCode"/> for the type-code mapping. Output matches
|
||||||
|
/// the byte-for-byte capture for the same inputs.
|
||||||
/// When MinEU/MaxEU/MinRaw/MaxRaw are all defaults (0/100/0/100) emits the compact
|
/// When MinEU/MaxEU/MinRaw/MaxRaw are all defaults (0/100/0/100) emits the compact
|
||||||
/// `1A 03` scaling marker; otherwise emits `1F` + 4 doubles in order.
|
/// `1A 03` scaling marker; otherwise emits `1F` + 4 doubles in order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ public sealed class HistorianTagWriteProtocolTests
|
|||||||
+ "09120052657465737453646B5772697465496E7432"
|
+ "09120052657465737453646B5772697465496E7432"
|
||||||
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
||||||
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000549B0E36EDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E8030000549B0E36EDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
||||||
// UInt1/Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the data-type code byte differs.
|
// Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the data-type code byte differs.
|
||||||
[InlineData(
|
[InlineData(
|
||||||
AVEVA.Historian.Client.Models.HistorianDataType.Int8,
|
AVEVA.Historian.Client.Models.HistorianDataType.Int8,
|
||||||
"RetestSdkWriteDouble", 0x01dcdbed24988f3aL,
|
"RetestSdkWriteDouble", 0x01dcdbed24988f3aL,
|
||||||
@@ -77,13 +77,6 @@ public sealed class HistorianTagWriteProtocolTests
|
|||||||
+ "09140052657465737453646B5772697465446F75626C65"
|
+ "09140052657465737453646B5772697465446F75626C65"
|
||||||
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
||||||
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
||||||
[InlineData(
|
|
||||||
AVEVA.Historian.Client.Models.HistorianDataType.UInt1,
|
|
||||||
"RetestSdkWriteDouble", 0x01dcdbed24988f3aL,
|
|
||||||
"4E6703000100000004C6020800000000000000000000000000000000"
|
|
||||||
+ "09140052657465737453646B5772697465446F75626C65"
|
|
||||||
+ "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"
|
|
||||||
+ "09180053444B2077726974652D52452073616E64626F78207461670904004D44415302010100000001E80300003A8F9824EDDBDC011A030904007465737410270000000000000000F03FFE00")]
|
|
||||||
public void SerializeAnalogCTagMetadata_PerDataType_MatchesCapturedNativeBytes(
|
public void SerializeAnalogCTagMetadata_PerDataType_MatchesCapturedNativeBytes(
|
||||||
AVEVA.Historian.Client.Models.HistorianDataType dataType,
|
AVEVA.Historian.Client.Models.HistorianDataType dataType,
|
||||||
string tagName,
|
string tagName,
|
||||||
@@ -271,6 +264,9 @@ public sealed class HistorianTagWriteProtocolTests
|
|||||||
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.SingleByteString));
|
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.SingleByteString));
|
||||||
Assert.Throws<ProtocolEvidenceMissingException>(
|
Assert.Throws<ProtocolEvidenceMissingException>(
|
||||||
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.Int1));
|
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.Int1));
|
||||||
|
// UInt1 re-gated: historian creates a degenerate UInt1 analog tag (null type descriptor) — see pending notes.
|
||||||
|
Assert.Throws<ProtocolEvidenceMissingException>(
|
||||||
|
() => HistorianTagWriteProtocol.GetAnalogDataTypeCode(AVEVA.Historian.Client.Models.HistorianDataType.UInt1));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -21,13 +21,11 @@ public sealed class WcfHistoricalWriteProtocolTests
|
|||||||
"4f4e0100380000002e00" + "ca9735f7f841b244b56f9c14ccfeac32" + "b09bc72fd701dd01" + "c000" + "c0100100" + "104b59f3e701dd01" + "0000000039300000")]
|
"4f4e0100380000002e00" + "ca9735f7f841b244b56f9c14ccfeac32" + "b09bc72fd701dd01" + "c000" + "c0100100" + "104b59f3e701dd01" + "0000000039300000")]
|
||||||
[InlineData(HistorianDataType.UInt4, 305419896d,
|
[InlineData(HistorianDataType.UInt4, 305419896d,
|
||||||
"4f4e0100380000002e00" + "e7ae22d8e4cc65439ebd8bcb09402974" + "602d6663d701dd01" + "c000" + "c0100100" + "498af726e801dd01" + "0000000078563412")]
|
"4f4e0100380000002e00" + "e7ae22d8e4cc65439ebd8bcb09402974" + "602d6663d701dd01" + "c000" + "c0100100" + "498af726e801dd01" + "0000000078563412")]
|
||||||
// UInt1/Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the data-type code / value bytes differ. Live round-trip confirms (gateway HistorianTypeRoundTripTests).
|
// Int8/UInt8 (H1 un-gate): prefix is the captured Double buffer; only the value bytes differ. Live round-trip confirms (gateway HistorianTypeRoundTripTests).
|
||||||
[InlineData(HistorianDataType.Int8, 100000d,
|
[InlineData(HistorianDataType.Int8, 100000d,
|
||||||
"4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000a086010000000000")]
|
"4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000a086010000000000")]
|
||||||
[InlineData(HistorianDataType.UInt8, 100001d,
|
[InlineData(HistorianDataType.UInt8, 100001d,
|
||||||
"4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000a186010000000000")]
|
"4f4e01003c0000003200" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000a186010000000000")]
|
||||||
[InlineData(HistorianDataType.UInt1, 7d,
|
|
||||||
"4f4e0100" + "35000000" + "2b00" + "7f970bb0b5a7344bb7bf6899ff06d027" + "c07d5f23d701dd01" + "c000" + "c0100100" + "08eff1e6e701dd01" + "00000000" + "07")]
|
|
||||||
public void SerializeAddStreamValuesBuffer_MatchesCapturedNativeBuffer(HistorianDataType dataType, double value, string capturedHex)
|
public void SerializeAddStreamValuesBuffer_MatchesCapturedNativeBuffer(HistorianDataType dataType, double value, string capturedHex)
|
||||||
{
|
{
|
||||||
byte[] captured = Convert.FromHexString(capturedHex);
|
byte[] captured = Convert.FromHexString(capturedHex);
|
||||||
@@ -54,6 +52,10 @@ public sealed class WcfHistoricalWriteProtocolTests
|
|||||||
Assert.Throws<ProtocolEvidenceMissingException>(() =>
|
Assert.Throws<ProtocolEvidenceMissingException>(() =>
|
||||||
HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
|
HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
|
||||||
Guid.NewGuid(), HistorianDataType.SingleByteString, DateTime.UtcNow, 1.0, DateTime.UtcNow));
|
Guid.NewGuid(), HistorianDataType.SingleByteString, DateTime.UtcNow, 1.0, DateTime.UtcNow));
|
||||||
|
// UInt1 re-gated: historian creates a degenerate UInt1 analog tag (null type descriptor) — see pending notes.
|
||||||
|
Assert.Throws<ProtocolEvidenceMissingException>(() =>
|
||||||
|
HistorianHistoricalWriteProtocol.SerializeAddStreamValuesBuffer(
|
||||||
|
Guid.NewGuid(), HistorianDataType.UInt1, DateTime.UtcNow, 1.0, DateTime.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user