Initial commit: managed .NET 10 AVEVA Historian SDK + reverse-engineering toolkit
Full read-only SDK (src/AVEVA.Historian.Client) implementing the CLAUDE.md required
surface against AVEVA Historian's binary WCF protocol — no native AVEVA runtime
dependency. All operations live-verified against a local Historian:
- ProbeAsync, ReadRawAsync, ReadAggregateAsync, ReadAtTimeAsync, ReadEventsAsync
- BrowseTagNamesAsync, GetTagMetadataAsync (17 native data-type codes mapped)
- GetConnectionStatusAsync, GetStoreForwardStatusAsync, GetSystemParameterAsync
- 108/108 unit + integration tests pass
Includes the reverse-engineering toolkit (tools/AVEVA.Historian.ReverseEngineering)
used to decode the protocol: WCF probes, IL inspection via dnlib, and IL-rewrite
instrumentation (instrument-wcf-{write,read}message etc.) plus the .NET Framework
trace harness (tools/AVEVA.Historian.NativeTraceHarness) for parity testing.
Sanitized handoff evidence under docs/reverse-engineering/. Native AVEVA binaries
(current/, aveva-install-x64/, aveva-install-x86/) are gitignored — fetch separately
from the AVEVA installer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
param(
|
||||
[string]$HostName = "localhost",
|
||||
[UInt16]$Port = 32568,
|
||||
[string]$UserName = $null,
|
||||
[int]$ConnectionWaitSeconds = 15,
|
||||
[switch]$IntegratedSecurity,
|
||||
[switch]$UseArchestrAUser
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function ConvertTo-PlainText {
|
||||
param([Security.SecureString]$SecureString)
|
||||
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
|
||||
try {
|
||||
[Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
|
||||
}
|
||||
finally {
|
||||
if ($bstr -ne [IntPtr]::Zero) {
|
||||
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Set-PropertyIfPresent {
|
||||
param(
|
||||
[object]$Object,
|
||||
[string]$Name,
|
||||
[object]$Value
|
||||
)
|
||||
|
||||
$property = $Object.GetType().GetProperty($Name)
|
||||
if ($null -ne $property -and $property.CanWrite) {
|
||||
$property.SetValue($Object, $Value, $null)
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PropertyText {
|
||||
param(
|
||||
[object]$Object,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
if ($null -eq $Object) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$property = $Object.GetType().GetProperty($Name)
|
||||
if ($null -eq $property -or -not $property.CanRead) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$value = $property.GetValue($Object, $null)
|
||||
if ($null -eq $value) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $value.ToString()
|
||||
}
|
||||
|
||||
function Get-PropertyValue {
|
||||
param(
|
||||
[object]$Object,
|
||||
[string]$Name
|
||||
)
|
||||
|
||||
if ($null -eq $Object) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$property = $Object.GetType().GetProperty($Name)
|
||||
if ($null -eq $property -or -not $property.CanRead) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $property.GetValue($Object, $null)
|
||||
}
|
||||
|
||||
function Get-UserNameShape {
|
||||
param([string]$Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
return "empty"
|
||||
}
|
||||
|
||||
if ($Value.Contains("\")) {
|
||||
return "domain-or-machine-qualified"
|
||||
}
|
||||
|
||||
if ($Value.Contains("@")) {
|
||||
return "upn"
|
||||
}
|
||||
|
||||
return "unqualified"
|
||||
}
|
||||
|
||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$dllDir = Join-Path $repoRoot "current"
|
||||
$managedDll = Join-Path $dllDir "aahClientManaged.dll"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $managedDll)) {
|
||||
throw "Missing aahClientManaged.dll at $managedDll"
|
||||
}
|
||||
|
||||
if (-not $IntegratedSecurity -and [string]::IsNullOrWhiteSpace($UserName)) {
|
||||
$defaultUser = "{0}\{1}" -f $env:COMPUTERNAME, $env:USERNAME
|
||||
$entered = Read-Host "Historian user [$defaultUser]"
|
||||
if ([string]::IsNullOrWhiteSpace($entered)) {
|
||||
$UserName = $defaultUser
|
||||
}
|
||||
else {
|
||||
$UserName = $entered
|
||||
}
|
||||
}
|
||||
|
||||
$plainPassword = $null
|
||||
if (-not $IntegratedSecurity) {
|
||||
$securePassword = Read-Host "Historian password" -AsSecureString
|
||||
$plainPassword = ConvertTo-PlainText $securePassword
|
||||
}
|
||||
|
||||
[AppDomain]::CurrentDomain.add_AssemblyResolve({
|
||||
param($sender, $eventArgs)
|
||||
|
||||
$assemblyName = New-Object Reflection.AssemblyName($eventArgs.Name)
|
||||
$candidate = Join-Path $script:dllDir ($assemblyName.Name + ".dll")
|
||||
if (Test-Path -LiteralPath $candidate) {
|
||||
return [Reflection.Assembly]::LoadFrom($candidate)
|
||||
}
|
||||
|
||||
return $null
|
||||
})
|
||||
|
||||
Push-Location $dllDir
|
||||
try {
|
||||
$assembly = [Reflection.Assembly]::LoadFrom($managedDll)
|
||||
$accessType = $assembly.GetType("ArchestrA.HistorianAccess", $true)
|
||||
$argsType = $assembly.GetType("ArchestrA.HistorianConnectionArgs", $true)
|
||||
$statusType = $assembly.GetType("ArchestrA.HistorianConnectionStatus", $true)
|
||||
$errorType = $assembly.GetType("ArchestrA.HistorianAccessError", $true)
|
||||
$connectionType = $assembly.GetType("ArchestrA.HistorianConnectionType", $true)
|
||||
|
||||
$argsObject = [Activator]::CreateInstance($argsType)
|
||||
Set-PropertyIfPresent $argsObject "ServerName" $HostName
|
||||
Set-PropertyIfPresent $argsObject "TcpPort" $Port
|
||||
Set-PropertyIfPresent $argsObject "ReadOnly" $true
|
||||
Set-PropertyIfPresent $argsObject "IntegratedSecurity" ([bool]$IntegratedSecurity)
|
||||
Set-PropertyIfPresent $argsObject "UseArchestrAUser" ([bool]$UseArchestrAUser)
|
||||
Set-PropertyIfPresent $argsObject "ConnectionType" ([Enum]::Parse($connectionType, "Process"))
|
||||
|
||||
if (-not $IntegratedSecurity) {
|
||||
Set-PropertyIfPresent $argsObject "UserName" $UserName
|
||||
Set-PropertyIfPresent $argsObject "Password" $plainPassword
|
||||
}
|
||||
|
||||
$access = [Activator]::CreateInstance($accessType)
|
||||
$errorObject = [Activator]::CreateInstance($errorType)
|
||||
$openParameterTypes = [Type[]]@($argsType, $errorType.MakeByRefType())
|
||||
$openMethod = $accessType.GetMethod("OpenConnection", $openParameterTypes)
|
||||
if ($null -eq $openMethod) {
|
||||
throw "Could not find HistorianAccess.OpenConnection(HistorianConnectionArgs, HistorianAccessError ByRef)."
|
||||
}
|
||||
|
||||
$invokeArgs = New-Object "object[]" 2
|
||||
$invokeArgs[0] = $argsObject
|
||||
$invokeArgs[1] = $errorObject
|
||||
$success = $false
|
||||
$exceptionText = $null
|
||||
|
||||
try {
|
||||
$success = [bool]$openMethod.Invoke($access, $invokeArgs)
|
||||
$errorObject = $invokeArgs[1]
|
||||
}
|
||||
catch [Reflection.TargetInvocationException] {
|
||||
$inner = $_.Exception.InnerException
|
||||
if ($null -ne $inner) {
|
||||
$exceptionText = "{0}: {1}" -f $inner.GetType().FullName, $inner.Message
|
||||
}
|
||||
else {
|
||||
$exceptionText = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
|
||||
$connected = $false
|
||||
$pending = $false
|
||||
$statusErrorOccurred = $false
|
||||
$statusError = $null
|
||||
$getStatusMethod = $accessType.GetMethod("GetConnectionStatus", [Type[]]@($statusType.MakeByRefType()))
|
||||
if ($null -ne $getStatusMethod) {
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds([Math]::Max($ConnectionWaitSeconds, 1))
|
||||
do {
|
||||
$statusObject = [Activator]::CreateInstance($statusType)
|
||||
$statusArgs = New-Object "object[]" 1
|
||||
$statusArgs[0] = $statusObject
|
||||
[void]$getStatusMethod.Invoke($access, $statusArgs)
|
||||
$statusObject = $statusArgs[0]
|
||||
$connected = [bool](Get-PropertyValue $statusObject "ConnectedToServer")
|
||||
$pending = [bool](Get-PropertyValue $statusObject "Pending")
|
||||
$statusErrorOccurred = [bool](Get-PropertyValue $statusObject "ErrorOccurred")
|
||||
$statusError = Get-PropertyValue $statusObject "Error"
|
||||
if (($connected -and -not $pending) -or $statusErrorOccurred -or (-not $connected -and -not $pending)) {
|
||||
break
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 250
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
}
|
||||
|
||||
$result = [ordered]@{
|
||||
Operation = "aahClientManaged.OpenConnection"
|
||||
HostName = $HostName
|
||||
Port = $Port
|
||||
IntegratedSecurity = [bool]$IntegratedSecurity
|
||||
UseArchestrAUser = [bool]$UseArchestrAUser
|
||||
UserNameShape = Get-UserNameShape $UserName
|
||||
Success = $success
|
||||
ConnectedToServer = $connected
|
||||
ConnectionPending = $pending
|
||||
ConnectionErrorOccurred = $statusErrorOccurred
|
||||
Exception = $exceptionText
|
||||
ErrorType = Get-PropertyText $errorObject "ErrorType"
|
||||
ErrorCode = Get-PropertyText $errorObject "ErrorCode"
|
||||
ErrorDescription = Get-PropertyText $errorObject "ErrorDescription"
|
||||
ErrorServerName = Get-PropertyText $errorObject "ServerName"
|
||||
ConnectionStatusErrorType = Get-PropertyText $statusError "ErrorType"
|
||||
ConnectionStatusErrorCode = Get-PropertyText $statusError "ErrorCode"
|
||||
ConnectionStatusErrorDescription = Get-PropertyText $statusError "ErrorDescription"
|
||||
}
|
||||
|
||||
$result | ConvertTo-Json -Depth 4
|
||||
|
||||
if ($success) {
|
||||
$closeError = [Activator]::CreateInstance($errorType)
|
||||
$closeParameterTypes = [Type[]]@($errorType.MakeByRefType())
|
||||
$closeMethod = $accessType.GetMethod("CloseConnection", $closeParameterTypes)
|
||||
if ($null -ne $closeMethod) {
|
||||
$closeArgs = New-Object "object[]" 1
|
||||
$closeArgs[0] = $closeError
|
||||
[void]$closeMethod.Invoke($access, $closeArgs)
|
||||
}
|
||||
}
|
||||
|
||||
$dispose = $access -as [IDisposable]
|
||||
if ($null -ne $dispose) {
|
||||
$dispose.Dispose()
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $plainPassword) {
|
||||
$plainPassword = $null
|
||||
}
|
||||
|
||||
Pop-Location
|
||||
}
|
||||
Reference in New Issue
Block a user