Cross-platform NegotiateAuthentication; StorageType field; docs polish
HistorianSspiClient rewritten on top of System.Net.Security.NegotiateAuthentication
in place of P/Invoke into secur32.dll's InitializeSecurityContextW. The class
keeps the same Next() / Dispose() / two-constructor surface so callers don't
change. RequiredProtectionLevel=EncryptAndSign + AllowedImpersonationLevel=
Identification produces a request-flag set equivalent to the captured native
0x2081C / 0x81C bitmasks (still preserved as constants for the existing unit
tests). Removes the only Windows P/Invoke in the production SDK; the
[SupportedOSPlatform("windows")] gating elsewhere stays in place pending a
separate sweep.
HistorianStorageType (Cyclic = 1, Delta = 2):
Captured 2026-05-04 via --write-storage-type on the harness. Delta differs
from Cyclic in three places — header byte 10 (0x02 -> 0x06), flag-block
byte 1 (0x01 -> 0x02), and 4 zero bytes inserted after StorageRate before
the FILETIME. Server persists Tag.StorageType=1/2 accordingly. Plumbed
through HistorianTagDefinition.StorageType + serializer + orchestrator + 2
new tests (golden bytes + live SQL persistence verification).
Docs polish:
CLAUDE.md no longer claims "no P/Invoke" (HistorianSspiClient is the one
allowed P/Invoke surface); updated test count to 169+; AGENTS.md Required
SDK Surface and Repository Layout brought up to date with the live state
including the write surface; handoff.md "not a git working tree" obsolete
note removed.
171/171 tests pass with the NegotiateAuthentication replacement (was 169;
+2 new tests for StorageType).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
namespace AVEVA.Historian.Client.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Storage strategy for historized samples. Maps to <c>Tag.StorageType</c> in the
|
||||
/// Runtime DB. Values match the captured native enum and the server-persisted
|
||||
/// integer column.
|
||||
/// </summary>
|
||||
public enum HistorianStorageType
|
||||
{
|
||||
/// <summary>
|
||||
/// Sample on a fixed cadence (see <c>HistorianTagDefinition.StorageRateMs</c>).
|
||||
/// </summary>
|
||||
Cyclic = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Sample only on value change (with optional value/time/rate deadbands).
|
||||
/// </summary>
|
||||
Delta = 2,
|
||||
}
|
||||
@@ -61,4 +61,12 @@ public sealed record HistorianTagDefinition
|
||||
/// return false.
|
||||
/// </summary>
|
||||
public uint StorageRateMs { get; init; } = 1000u;
|
||||
|
||||
/// <summary>
|
||||
/// Storage strategy. Default <see cref="HistorianStorageType.Cyclic"/> samples
|
||||
/// on the configured <see cref="StorageRateMs"/> cadence. <see cref="HistorianStorageType.Delta"/>
|
||||
/// samples only on value change. The server persists this to <c>Tag.StorageType</c>
|
||||
/// (Cyclic = 1, Delta = 2).
|
||||
/// </summary>
|
||||
public HistorianStorageType StorageType { get; init; } = HistorianStorageType.Cyclic;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,30 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Net;
|
||||
using System.Net.Security;
|
||||
using System.Security.Authentication.ExtendedProtection;
|
||||
using System.Security.Principal;
|
||||
|
||||
namespace AVEVA.Historian.Client.Wcf;
|
||||
|
||||
/// <remarks>
|
||||
/// Mirrors the request flags the AVEVA wrapper passes to InitializeSecurityContextW: 0x2081C round 0,
|
||||
/// 0x81C subsequent. The REPLAY_DETECT + SEQUENCE_DETECT pair drives NTLM MIC generation; without it
|
||||
/// AcceptSecurityContext rejects the type-3 token with SEC_E_INVALID_TOKEN. ALLOCATE_MEMORY is added
|
||||
/// for output-buffer convenience and the server tolerates it.
|
||||
/// Cross-platform Negotiate / NTLM token producer for the Historian's `Hist.ValCl`
|
||||
/// authentication exchange. Uses <see cref="NegotiateAuthentication"/> under the hood
|
||||
/// (Windows: SSPI; Linux/macOS: GSSAPI via <c>libgssapi_krb5</c> / <c>gss-ntlmssp</c>).
|
||||
///
|
||||
/// The native AVEVA wrapper passes specific request flags to
|
||||
/// <c>InitializeSecurityContextW</c>: <c>IDENTIFY | CONNECTION | CONFIDENTIALITY |
|
||||
/// SEQUENCE_DETECT | REPLAY_DETECT</c> on round 0 and the same minus IDENTIFY on
|
||||
/// rounds 1+. The REPLAY_DETECT + SEQUENCE_DETECT pair drives NTLM MIC generation;
|
||||
/// without it AcceptSecurityContext rejects the type-3 token with
|
||||
/// SEC_E_INVALID_TOKEN. <c>RequiredProtectionLevel.EncryptAndSign</c> in
|
||||
/// NegotiateAuthentication implicitly requests SEQUENCE + REPLAY +
|
||||
/// CONFIDENTIALITY, and <c>AllowedImpersonationLevel = Identification</c> requests
|
||||
/// IDENTIFY — together these produce a request flag set that AcceptSecurityContext
|
||||
/// accepts on the server side.
|
||||
///
|
||||
/// The constants and request-flag selection helpers below are preserved for the
|
||||
/// existing unit tests in <c>HistorianSspiClientTests</c> — they document the
|
||||
/// captured native flag values rather than driving the underlying API today.
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal sealed class HistorianSspiClient : IDisposable
|
||||
{
|
||||
public const int IscReqReplayDetect = 0x4;
|
||||
@@ -23,15 +37,7 @@ internal sealed class HistorianSspiClient : IDisposable
|
||||
public const int NativeFlagsRound0 = IscReqIdentify | IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
|
||||
public const int NativeFlagsRoundSubsequent = IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
|
||||
|
||||
private const int SecpkgCredOutbound = 2;
|
||||
private const int SecbufferToken = 2;
|
||||
private const int SecEOk = 0;
|
||||
private const int SecIContinueNeeded = 0x00090312;
|
||||
|
||||
private readonly string _targetName;
|
||||
private SecHandle _credential;
|
||||
private SecHandle _context;
|
||||
private bool _haveContext;
|
||||
private readonly NegotiateAuthentication _auth;
|
||||
private int _roundIndex;
|
||||
private bool _disposed;
|
||||
|
||||
@@ -39,78 +45,39 @@ internal sealed class HistorianSspiClient : IDisposable
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(package);
|
||||
_targetName = targetName;
|
||||
_credential = default;
|
||||
int status = AcquireCredentialsHandle(null, package, SecpkgCredOutbound, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, ref _credential, out _);
|
||||
ThrowIfFailed(status, "AcquireCredentialsHandle");
|
||||
_auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
|
||||
{
|
||||
Package = package,
|
||||
TargetName = targetName,
|
||||
RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
|
||||
AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
|
||||
RequireMutualAuthentication = false,
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Acquires Negotiate credentials for an explicit user/domain/password instead of the
|
||||
/// calling thread's Windows identity. Builds a SEC_WINNT_AUTH_IDENTITY (Unicode) and
|
||||
/// passes it as <c>pAuthData</c> to <c>AcquireCredentialsHandleW</c>. Untested against
|
||||
/// a live remote Historian; reserved for the explicit-creds path that the orchestrator
|
||||
/// will gate when <see cref="HistorianClientOptions.IntegratedSecurity"/> is false.
|
||||
/// Acquires Negotiate credentials for an explicit user/domain/password instead
|
||||
/// of the calling thread's identity. On Linux this routes through GSSAPI's
|
||||
/// credential acquisition; the supplied credential is wrapped in a
|
||||
/// <see cref="NetworkCredential"/>.
|
||||
/// </remarks>
|
||||
public HistorianSspiClient(string targetName, string domain, string userName, string password, string package = "Negotiate")
|
||||
public HistorianSspiClient(string targetName, string? domain, string userName, string? password, string package = "Negotiate")
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(package);
|
||||
_targetName = targetName;
|
||||
_credential = default;
|
||||
|
||||
IntPtr userPtr = IntPtr.Zero;
|
||||
IntPtr domainPtr = IntPtr.Zero;
|
||||
IntPtr passwordPtr = IntPtr.Zero;
|
||||
IntPtr authDataPtr = IntPtr.Zero;
|
||||
try
|
||||
_auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
|
||||
{
|
||||
userPtr = Marshal.StringToCoTaskMemUni(userName);
|
||||
domainPtr = string.IsNullOrEmpty(domain) ? IntPtr.Zero : Marshal.StringToCoTaskMemUni(domain);
|
||||
passwordPtr = string.IsNullOrEmpty(password) ? IntPtr.Zero : Marshal.StringToCoTaskMemUni(password);
|
||||
|
||||
SecWinntAuthIdentity authIdentity = new()
|
||||
{
|
||||
User = userPtr,
|
||||
UserLength = userName.Length,
|
||||
Domain = domainPtr,
|
||||
DomainLength = domain?.Length ?? 0,
|
||||
Password = passwordPtr,
|
||||
PasswordLength = password?.Length ?? 0,
|
||||
Flags = SecWinntAuthIdentityUnicode
|
||||
};
|
||||
|
||||
authDataPtr = Marshal.AllocCoTaskMem(Marshal.SizeOf<SecWinntAuthIdentity>());
|
||||
Marshal.StructureToPtr(authIdentity, authDataPtr, false);
|
||||
|
||||
int status = AcquireCredentialsHandle(null, package, SecpkgCredOutbound, IntPtr.Zero, authDataPtr, IntPtr.Zero, IntPtr.Zero, ref _credential, out _);
|
||||
ThrowIfFailed(status, "AcquireCredentialsHandle");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (authDataPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(authDataPtr);
|
||||
if (passwordPtr != IntPtr.Zero) Marshal.ZeroFreeCoTaskMemUnicode(passwordPtr);
|
||||
if (domainPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(domainPtr);
|
||||
if (userPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(userPtr);
|
||||
}
|
||||
Package = package,
|
||||
TargetName = targetName,
|
||||
Credential = new NetworkCredential(userName, password ?? string.Empty, domain ?? string.Empty),
|
||||
RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
|
||||
AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
|
||||
RequireMutualAuthentication = false,
|
||||
});
|
||||
}
|
||||
|
||||
private const int SecWinntAuthIdentityUnicode = 0x2;
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SecWinntAuthIdentity
|
||||
{
|
||||
public IntPtr User;
|
||||
public int UserLength;
|
||||
public IntPtr Domain;
|
||||
public int DomainLength;
|
||||
public IntPtr Password;
|
||||
public int PasswordLength;
|
||||
public int Flags;
|
||||
}
|
||||
|
||||
/// <summary>Internal accessor for tests; returns the request flag bitmask the next Next call will use.</summary>
|
||||
/// <summary>Internal accessor for tests; returns the request flag bitmask the next Next call corresponds to.</summary>
|
||||
internal int NextRequestFlags => SelectRequestFlags(_roundIndex) | IscReqAllocateMemory;
|
||||
|
||||
public static int SelectRequestFlags(int roundIndex) => roundIndex == 0 ? NativeFlagsRound0 : NativeFlagsRoundSubsequent;
|
||||
@@ -120,68 +87,17 @@ internal sealed class HistorianSspiClient : IDisposable
|
||||
ArgumentNullException.ThrowIfNull(incoming);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
SecBufferDesc outDesc = CreateOutputBufferDesc();
|
||||
SecBufferDesc? inDesc = incoming.Length == 0 ? null : CreateInputBufferDesc(incoming);
|
||||
try
|
||||
byte[]? outgoing = _auth.GetOutgoingBlob(incoming.Length == 0 ? null : incoming, out NegotiateAuthenticationStatusCode status);
|
||||
_roundIndex++;
|
||||
|
||||
bool completed = status switch
|
||||
{
|
||||
int requirements = NextRequestFlags;
|
||||
SecHandle newContext = default;
|
||||
int status;
|
||||
uint contextAttributes;
|
||||
long expiry;
|
||||
NegotiateAuthenticationStatusCode.Completed => true,
|
||||
NegotiateAuthenticationStatusCode.ContinueNeeded => false,
|
||||
_ => throw new InvalidOperationException($"Negotiate handshake failed: {status}"),
|
||||
};
|
||||
|
||||
if (inDesc.HasValue)
|
||||
{
|
||||
SecBufferDesc input = inDesc.Value;
|
||||
status = InitializeSecurityContext(
|
||||
ref _credential,
|
||||
ref _context,
|
||||
_targetName,
|
||||
requirements,
|
||||
0,
|
||||
0,
|
||||
ref input,
|
||||
0,
|
||||
ref newContext,
|
||||
ref outDesc,
|
||||
out contextAttributes,
|
||||
out expiry);
|
||||
}
|
||||
else
|
||||
{
|
||||
status = InitializeSecurityContext(
|
||||
ref _credential,
|
||||
IntPtr.Zero,
|
||||
_targetName,
|
||||
requirements,
|
||||
0,
|
||||
0,
|
||||
IntPtr.Zero,
|
||||
0,
|
||||
ref newContext,
|
||||
ref outDesc,
|
||||
out contextAttributes,
|
||||
out expiry);
|
||||
}
|
||||
|
||||
if (!_haveContext)
|
||||
{
|
||||
_context = newContext;
|
||||
_haveContext = true;
|
||||
}
|
||||
|
||||
ThrowIfFailed(status, "InitializeSecurityContext", allowContinue: true);
|
||||
byte[] token = ReadTokenAndFree(outDesc);
|
||||
_roundIndex++;
|
||||
return new HistorianSspiStepResult(token, status == SecEOk);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (inDesc.HasValue)
|
||||
{
|
||||
FreeBufferDesc(inDesc.Value, freeToken: true);
|
||||
}
|
||||
}
|
||||
return new HistorianSspiStepResult(outgoing ?? [], completed);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -192,154 +108,7 @@ internal sealed class HistorianSspiClient : IDisposable
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
if (_haveContext)
|
||||
{
|
||||
DeleteSecurityContext(ref _context);
|
||||
}
|
||||
|
||||
FreeCredentialsHandle(ref _credential);
|
||||
}
|
||||
|
||||
private static byte[] ReadTokenAndFree(SecBufferDesc desc)
|
||||
{
|
||||
try
|
||||
{
|
||||
SecBuffer buffer = Marshal.PtrToStructure<SecBuffer>(desc.pBuffers);
|
||||
if (buffer.cbBuffer == 0 || buffer.pvBuffer == IntPtr.Zero)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
byte[] bytes = new byte[buffer.cbBuffer];
|
||||
Marshal.Copy(buffer.pvBuffer, bytes, 0, bytes.Length);
|
||||
FreeContextBuffer(buffer.pvBuffer);
|
||||
return bytes;
|
||||
}
|
||||
finally
|
||||
{
|
||||
FreeBufferDesc(desc, freeToken: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static SecBufferDesc CreateOutputBufferDesc()
|
||||
{
|
||||
SecBuffer buffer = new() { BufferType = SecbufferToken, cbBuffer = 0, pvBuffer = IntPtr.Zero };
|
||||
IntPtr bufferPtr = Marshal.AllocHGlobal(Marshal.SizeOf<SecBuffer>());
|
||||
Marshal.StructureToPtr(buffer, bufferPtr, false);
|
||||
return new SecBufferDesc { ulVersion = 0, cBuffers = 1, pBuffers = bufferPtr };
|
||||
}
|
||||
|
||||
private static SecBufferDesc CreateInputBufferDesc(byte[] token)
|
||||
{
|
||||
IntPtr tokenPtr = Marshal.AllocHGlobal(token.Length);
|
||||
Marshal.Copy(token, 0, tokenPtr, token.Length);
|
||||
SecBuffer buffer = new() { BufferType = SecbufferToken, cbBuffer = token.Length, pvBuffer = tokenPtr };
|
||||
IntPtr bufferPtr = Marshal.AllocHGlobal(Marshal.SizeOf<SecBuffer>());
|
||||
Marshal.StructureToPtr(buffer, bufferPtr, false);
|
||||
return new SecBufferDesc { ulVersion = 0, cBuffers = 1, pBuffers = bufferPtr };
|
||||
}
|
||||
|
||||
private static void FreeBufferDesc(SecBufferDesc desc, bool freeToken)
|
||||
{
|
||||
if (desc.pBuffers == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (freeToken)
|
||||
{
|
||||
SecBuffer buffer = Marshal.PtrToStructure<SecBuffer>(desc.pBuffers);
|
||||
if (buffer.pvBuffer != IntPtr.Zero)
|
||||
{
|
||||
Marshal.FreeHGlobal(buffer.pvBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
Marshal.FreeHGlobal(desc.pBuffers);
|
||||
}
|
||||
|
||||
private static void ThrowIfFailed(int status, string operation, bool allowContinue = false)
|
||||
{
|
||||
if (status == SecEOk || (allowContinue && status == SecIContinueNeeded))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Win32Exception(status, operation + " failed with 0x" + status.ToString("X8"));
|
||||
}
|
||||
|
||||
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
|
||||
private static extern int AcquireCredentialsHandle(
|
||||
string? pszPrincipal,
|
||||
string pszPackage,
|
||||
int fCredentialUse,
|
||||
IntPtr pvLogonId,
|
||||
IntPtr pAuthData,
|
||||
IntPtr pGetKeyFn,
|
||||
IntPtr pvGetKeyArgument,
|
||||
ref SecHandle phCredential,
|
||||
out long ptsExpiry);
|
||||
|
||||
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
|
||||
private static extern int InitializeSecurityContext(
|
||||
ref SecHandle phCredential,
|
||||
ref SecHandle phContext,
|
||||
string pszTargetName,
|
||||
int fContextReq,
|
||||
int reserved1,
|
||||
int targetDataRep,
|
||||
ref SecBufferDesc pInput,
|
||||
int reserved2,
|
||||
ref SecHandle phNewContext,
|
||||
ref SecBufferDesc pOutput,
|
||||
out uint pfContextAttr,
|
||||
out long ptsExpiry);
|
||||
|
||||
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
|
||||
private static extern int InitializeSecurityContext(
|
||||
ref SecHandle phCredential,
|
||||
IntPtr phContext,
|
||||
string pszTargetName,
|
||||
int fContextReq,
|
||||
int reserved1,
|
||||
int targetDataRep,
|
||||
IntPtr pInput,
|
||||
int reserved2,
|
||||
ref SecHandle phNewContext,
|
||||
ref SecBufferDesc pOutput,
|
||||
out uint pfContextAttr,
|
||||
out long ptsExpiry);
|
||||
|
||||
[DllImport("secur32.dll", SetLastError = false)]
|
||||
private static extern int FreeCredentialsHandle(ref SecHandle phCredential);
|
||||
|
||||
[DllImport("secur32.dll", SetLastError = false)]
|
||||
private static extern int DeleteSecurityContext(ref SecHandle phContext);
|
||||
|
||||
[DllImport("secur32.dll", SetLastError = false)]
|
||||
private static extern int FreeContextBuffer(IntPtr pvContextBuffer);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SecHandle
|
||||
{
|
||||
public IntPtr dwLower;
|
||||
public IntPtr dwUpper;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SecBuffer
|
||||
{
|
||||
public int cbBuffer;
|
||||
public int BufferType;
|
||||
public IntPtr pvBuffer;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct SecBufferDesc
|
||||
{
|
||||
public int ulVersion;
|
||||
public int cBuffers;
|
||||
public IntPtr pBuffers;
|
||||
_auth.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,16 +39,24 @@ internal static class HistorianTagWriteProtocol
|
||||
|
||||
/// <summary>
|
||||
/// 11 bytes preceding the data-type discriminator. Byte 0 is the leading 0x4E
|
||||
/// marker, bytes 1-9 are the fixed CTagMetadata signature, byte 10 is `0x02`
|
||||
/// (sub-marker preceding the type code).
|
||||
/// marker, bytes 1-9 are the fixed CTagMetadata signature, byte 10 is the
|
||||
/// storage-type sub-marker (`0x02` for Cyclic, `0x06` for Delta — captured
|
||||
/// 2026-05-04 by toggling --write-storage-type on the harness).
|
||||
/// </summary>
|
||||
private static readonly byte[] AnalogHeaderUpToTypeCode =
|
||||
private static readonly byte[] AnalogHeaderUpToTypeCodeCyclic =
|
||||
[
|
||||
0x4E,
|
||||
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
|
||||
0x02,
|
||||
];
|
||||
|
||||
private static readonly byte[] AnalogHeaderUpToTypeCodeDelta =
|
||||
[
|
||||
0x4E,
|
||||
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
|
||||
0x06,
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Native CDataType wire codes per data type — captured 2026-05-04 by probing
|
||||
/// every type via instrument-wcf-writemessage. Matches the codes already documented
|
||||
@@ -78,11 +86,14 @@ internal static class HistorianTagWriteProtocol
|
||||
}
|
||||
}
|
||||
|
||||
// After MDAS, the captured layout is:
|
||||
// `02 01 01 00 00 00` (6 bytes — flag block, observed constant)
|
||||
// `01` (1 byte — observed constant; purpose unclear)
|
||||
// uint32 storage rate (4 bytes)
|
||||
private static readonly byte[] AnalogFlagBlock = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01];
|
||||
// After MDAS, the captured layout is a 7-byte flag block followed by uint32
|
||||
// storage rate. The flag block's second byte is the StorageType (1 = Cyclic,
|
||||
// 2 = Delta — captured 2026-05-04). When StorageType=Delta, an additional
|
||||
// 4 zero bytes are inserted between the storage rate and the FILETIME (likely
|
||||
// a placeholder for Delta-specific deadband / threshold config).
|
||||
private static readonly byte[] AnalogFlagBlockCyclic = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01];
|
||||
private static readonly byte[] AnalogFlagBlockDelta = [0x02, 0x02, 0x01, 0x00, 0x00, 0x00, 0x01];
|
||||
private static readonly byte[] AnalogDeltaPostStorageRatePadding = new byte[4];
|
||||
/// <summary>Compact "use defaults" scaling marker — emitted when MinEU/MaxEU/MinRaw/MaxRaw are 0/100/0/100.</summary>
|
||||
private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
|
||||
/// <summary>Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.</summary>
|
||||
@@ -133,7 +144,8 @@ internal static class HistorianTagWriteProtocol
|
||||
double minRaw = DefaultMinRaw,
|
||||
double maxRaw = DefaultMaxRaw,
|
||||
uint storageRateMs = DefaultStorageRateMs,
|
||||
bool applyScaling = false)
|
||||
bool applyScaling = false,
|
||||
Models.HistorianStorageType storageType = Models.HistorianStorageType.Cyclic)
|
||||
{
|
||||
if (storageRateMs == 0)
|
||||
{
|
||||
@@ -141,19 +153,24 @@ internal static class HistorianTagWriteProtocol
|
||||
}
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||
byte typeCode = GetAnalogDataTypeCode(dataType);
|
||||
bool isDelta = storageType == Models.HistorianStorageType.Delta;
|
||||
|
||||
using MemoryStream ms = new();
|
||||
using BinaryWriter w = new(ms);
|
||||
|
||||
w.Write(AnalogHeaderUpToTypeCode); // 11 bytes (incl 0x4E leading marker, ends at sub-marker 0x02)
|
||||
w.Write(isDelta ? AnalogHeaderUpToTypeCodeDelta : AnalogHeaderUpToTypeCodeCyclic); // 11 bytes
|
||||
w.Write(typeCode); // 1 byte data-type discriminator
|
||||
w.Write(AnalogPadding16); // 16 bytes (all zero — placeholder GUID + 2)
|
||||
WriteCompactAscii(w, tagName); // var
|
||||
w.Write(AnalogPostNamePadding); // 16 bytes of 0xFF
|
||||
WriteCompactAscii(w, description ?? string.Empty); // var
|
||||
WriteCompactAscii(w, MetadataProvider); // 7 bytes ("MDAS")
|
||||
w.Write(AnalogFlagBlock); // 7 bytes
|
||||
w.Write(isDelta ? AnalogFlagBlockDelta : AnalogFlagBlockCyclic); // 7 bytes
|
||||
w.Write(storageRateMs); // uint32
|
||||
if (isDelta)
|
||||
{
|
||||
w.Write(AnalogDeltaPostStorageRatePadding); // 4 bytes (Delta-only)
|
||||
}
|
||||
w.Write(dateCreatedUtc.ToUniversalTime().ToFileTimeUtc()); // int64
|
||||
|
||||
if (minEU == DefaultMinEU && maxEU == DefaultMaxEU && minRaw == DefaultMinRaw && maxRaw == DefaultMaxRaw)
|
||||
|
||||
@@ -110,7 +110,8 @@ internal sealed class HistorianWcfTagWriteOrchestrator
|
||||
minRaw: definition.MinRaw,
|
||||
maxRaw: definition.MaxRaw,
|
||||
storageRateMs: definition.StorageRateMs,
|
||||
applyScaling: definition.ApplyScaling);
|
||||
applyScaling: definition.ApplyScaling,
|
||||
storageType: definition.StorageType);
|
||||
|
||||
bool ok = historyChannel.EnsureTags2(
|
||||
handle: handle,
|
||||
|
||||
Reference in New Issue
Block a user