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:
@@ -6,6 +6,11 @@ namespace AVEVA.Historian.Client.Models;
|
||||
/// String/Int1/Int8/UInt8 types failed at native AddTag — likely require a different
|
||||
/// path and are intentionally not supported. MinEU/MaxEU/MinRaw/MaxRaw are now encoded
|
||||
/// into the wire payload (see <c>HistorianTagWriteProtocol</c>).
|
||||
///
|
||||
/// Semantics: <c>EnsureTagAsync</c> is an upsert. Calling it twice on the same
|
||||
/// <see cref="TagName"/> with different fields succeeds both times; the second call
|
||||
/// updates Description, MinEU, MaxEU, MinRaw, MaxRaw, and AnalogTag.Scaling on the
|
||||
/// existing row (verified 2026-05-04 by direct SQL inspection after sequential calls).
|
||||
/// </summary>
|
||||
public sealed record HistorianTagDefinition
|
||||
{
|
||||
@@ -28,18 +33,32 @@ public sealed record HistorianTagDefinition
|
||||
public double MaxEU { get; init; } = 100.0;
|
||||
|
||||
/// <summary>
|
||||
/// Raw lower bound (pre-scaling). Default 0. Note: with ApplyScaling=false (the
|
||||
/// only path the SDK currently exposes), the server appears to mirror MinRaw to
|
||||
/// MinEU on EnsureTags2 — verified 2026-05-04 against both native and managed
|
||||
/// clients with the same input. The value is sent on the wire but not persisted
|
||||
/// independently. To set distinct raw bounds, ApplyScaling=true plus a follow-up
|
||||
/// UpdateTags call would be required (not yet wired).
|
||||
/// Raw lower bound (pre-scaling). Default 0. Persisted distinctly only when
|
||||
/// <see cref="ApplyScaling"/> is true; with ApplyScaling=false the server mirrors
|
||||
/// this to MinEU on EnsureTags2 (verified 2026-05-04 against both native and
|
||||
/// managed clients).
|
||||
/// </summary>
|
||||
public double MinRaw { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw upper bound (pre-scaling). Default 100. See <see cref="MinRaw"/> for the
|
||||
/// server-side mirror caveat with ApplyScaling=false.
|
||||
/// ApplyScaling caveat.
|
||||
/// </summary>
|
||||
public double MaxRaw { get; init; } = 100.0;
|
||||
|
||||
/// <summary>
|
||||
/// When true, the server persists <see cref="MinRaw"/> / <see cref="MaxRaw"/> as
|
||||
/// distinct values from <see cref="MinEU"/> / <see cref="MaxEU"/> and sets
|
||||
/// <c>AnalogTag.Scaling</c> = 1. When false (default), the server mirrors MinRaw
|
||||
/// to MinEU and MaxRaw to MaxEU and sets <c>AnalogTag.Scaling</c> = 0.
|
||||
/// </summary>
|
||||
public bool ApplyScaling { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Storage rate in milliseconds. Default 1000ms. The server only accepts
|
||||
/// quantized values (observed valid set: 1000, 5000, 10000, 60000, 300000) —
|
||||
/// non-quantized values cause <see cref="HistorianClient.EnsureTagAsync"/> to
|
||||
/// return false.
|
||||
/// </summary>
|
||||
public uint StorageRateMs { get; init; } = 1000u;
|
||||
}
|
||||
|
||||
@@ -26,13 +26,12 @@ namespace AVEVA.Historian.Client.Wcf;
|
||||
/// compact ASCII engineering unit
|
||||
/// uint32 = 0x2710 (10000 — purpose unclear; observed constant)
|
||||
/// 8-byte double = 1.0 (likely IntegralDivisor)
|
||||
/// 2-byte trailer = FE 00
|
||||
/// 2-byte trailer = `FE 00` for ApplyScaling=false; `FE 01` for ApplyScaling=true
|
||||
/// </code>
|
||||
/// MinEU/MaxEU/MinRaw/MaxRaw fields and their wire positions are NOT yet decoded
|
||||
/// from the captured fixture (the test tag used the defaults). The serializer accepts
|
||||
/// those parameters from <see cref="Models.HistorianTagDefinition"/> but their wire
|
||||
/// representation is currently a TODO; for now they are not encoded into the
|
||||
/// payload — the server uses defaults from the AnalogTag table after creation.
|
||||
/// The trailer's second byte is the ApplyScaling flag — verified 2026-05-04 by
|
||||
/// capturing native CTagMetadata bytes for both values with identical
|
||||
/// MinEU/MaxEU/MinRaw/MaxRaw inputs and observing that the server persists distinct
|
||||
/// MinRaw/MaxRaw (and sets AnalogTag.Scaling=1) only when this byte is 0x01.
|
||||
/// </remarks>
|
||||
internal static class HistorianTagWriteProtocol
|
||||
{
|
||||
@@ -88,10 +87,14 @@ internal static class HistorianTagWriteProtocol
|
||||
private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
|
||||
/// <summary>Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.</summary>
|
||||
private static readonly byte[] AnalogScalingExplicitMarker = [0x1F, 0x00];
|
||||
// Native trailer is 2 bytes; the prior 5-byte version included WCF EndElement
|
||||
// closing markers (`01 01 01`) that the binary message encoder writes around the
|
||||
// <InBuff> element — those are not part of the buffer content.
|
||||
private static readonly byte[] AnalogTrailer = [0xFE, 0x00];
|
||||
// 2-byte trailer: `FE` marker + ApplyScaling byte (0x00 = false, 0x01 = true). Verified
|
||||
// against native captures by toggling ApplyScaling on the harness and confirming that
|
||||
// the server persists distinct MinRaw/MaxRaw + sets AnalogTag.Scaling=1 only when the
|
||||
// second byte is 0x01. The WCF binary encoder may split InBuff across two
|
||||
// Bytes8Text chunks (e.g., `9E B7 ... 9F 01 00`) which can make the trailer look
|
||||
// 1-byte from the wire, but the semantic CTagMetadata content is always 2 bytes.
|
||||
private static readonly byte[] AnalogTrailerScalingDisabled = [0xFE, 0x00];
|
||||
private static readonly byte[] AnalogTrailerScalingEnabled = [0xFE, 0x01];
|
||||
|
||||
private const double DefaultMinEU = 0.0;
|
||||
private const double DefaultMaxEU = 100.0;
|
||||
@@ -129,8 +132,13 @@ internal static class HistorianTagWriteProtocol
|
||||
double maxEU = DefaultMaxEU,
|
||||
double minRaw = DefaultMinRaw,
|
||||
double maxRaw = DefaultMaxRaw,
|
||||
uint storageRateMs = DefaultStorageRateMs)
|
||||
uint storageRateMs = DefaultStorageRateMs,
|
||||
bool applyScaling = false)
|
||||
{
|
||||
if (storageRateMs == 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(storageRateMs), "Storage rate must be > 0 ms.");
|
||||
}
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
|
||||
byte typeCode = GetAnalogDataTypeCode(dataType);
|
||||
|
||||
@@ -164,7 +172,7 @@ internal static class HistorianTagWriteProtocol
|
||||
WriteCompactAscii(w, engineeringUnit ?? string.Empty); // var
|
||||
w.Write(IntegralDivisorMagic); // uint32 (purpose unclear — captured constant)
|
||||
w.Write(1.0); // double
|
||||
w.Write(AnalogTrailer); // 2 bytes (FE 00)
|
||||
w.Write(applyScaling ? AnalogTrailerScalingEnabled : AnalogTrailerScalingDisabled);
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
@@ -108,7 +108,9 @@ internal sealed class HistorianWcfTagWriteOrchestrator
|
||||
minEU: definition.MinEU,
|
||||
maxEU: definition.MaxEU,
|
||||
minRaw: definition.MinRaw,
|
||||
maxRaw: definition.MaxRaw);
|
||||
maxRaw: definition.MaxRaw,
|
||||
storageRateMs: definition.StorageRateMs,
|
||||
applyScaling: definition.ApplyScaling);
|
||||
|
||||
bool ok = historyChannel.EnsureTags2(
|
||||
handle: handle,
|
||||
|
||||
Reference in New Issue
Block a user