Skip to main content
.NET Track

.NET & PI Web API

PI Web API works with any HTTP client. If your team runs .NET, you can use HttpClient with IHttpClientFactory, System.Text.Json source generation, and cancellation tokens to build production-grade PI integrations.

Why .NET for PI Web API?

Many PI System environments already run on Windows with .NET infrastructure. Using .NET for PI Web API integrations means:

Native Windows integration

Kerberos and NTLM authentication work out of the box with HttpClient and CredentialCache. No extra libraries needed.

Fits existing services

If you already have ASP.NET APIs, Windows Services, or Azure Functions, adding PI Web API calls is straightforward.

Strong typing with source generation

System.Text.Json source generators give you compile-time serialization with zero reflection overhead. Catch schema mismatches at build time.

AF SDK alternative

PI Web API over HTTP works cross-platform. If you need to move away from AF SDK or support Linux/.NET 8+, this is the path.

Typed models with System.Text.Json source generation

For production code, define C# models and use source-generated serialization instead of JsonElement. This is faster (no reflection), AOT-compatible, and catches schema issues at compile time.

PiWebApiModels.cscsharp
using System.Text.Json.Serialization;

// PI point metadata
public record PiPoint(
    [property: JsonPropertyName("WebId")] string WebId,
    [property: JsonPropertyName("Name")] string Name,
    [property: JsonPropertyName("Path")] string Path,
    [property: JsonPropertyName("PointType")] string PointType,
    [property: JsonPropertyName("Descriptor")] string? Descriptor
);

// A single timestamped value
public record TimedValue(
    [property: JsonPropertyName("Value")] object? Value,
    [property: JsonPropertyName("Timestamp")] DateTimeOffset Timestamp,
    [property: JsonPropertyName("Good")] bool Good,
    [property: JsonPropertyName("UnitsAbbreviation")] string? UnitsAbbreviation
);

// Collection response from recorded/interpolated endpoints
public record TimedValueList(
    [property: JsonPropertyName("Items")] List<TimedValue> Items,
    [property: JsonPropertyName("Links")] Dictionary<string, string>? Links
);

// Batch response wrapper
public record BatchResponse(
    [property: JsonPropertyName("Status")] int Status,
    [property: JsonPropertyName("Content")] JsonElement? Content
);

// Source-generated serializer context -- no reflection at runtime
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(PiPoint))]
[JsonSerializable(typeof(TimedValue))]
[JsonSerializable(typeof(TimedValueList))]
[JsonSerializable(typeof(Dictionary<string, BatchResponse>))]
[JsonSerializable(typeof(Dictionary<string, object>))]
public partial class PiJsonContext : JsonSerializerContext { }

IHttpClientFactory setup (ASP.NET Core)

In ASP.NET Core services, use IHttpClientFactory instead of creating HttpClient directly. This handles DNS changes, connection pooling, and socket exhaustion automatically.

Program.cs (service registration)csharp
using System.Net;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

// Register a named HttpClient for PI Web API
builder.Services.AddHttpClient("PiWebApi", (sp, client) =>
{
    var config = sp.GetRequiredService<IConfiguration>();
    client.BaseAddress = new Uri(config["PiWebApi:BaseUrl"]!);
    client.DefaultRequestHeaders.Accept.Add(
        new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
    client.Timeout = TimeSpan.FromSeconds(30);
})
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
{
    Credentials = CredentialCache.DefaultNetworkCredentials,
    // In production: trust your CA cert properly
    ServerCertificateCustomValidationCallback =
        HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
})
.AddStandardResilienceHandler();  // Polly v8 retry + circuit breaker

var app = builder.Build();
PiWebApiService.cscsharp
using System.Net.Http.Json;
using System.Text.Json;

public class PiWebApiService(IHttpClientFactory httpClientFactory)
{
    private readonly HttpClient _client = httpClientFactory.CreateClient("PiWebApi");

    public async Task<PiPoint?> GetPointByPathAsync(
        string server, string pointName, CancellationToken ct = default)
    {
        var path = Uri.EscapeDataString($"\\\\{server}\\{pointName}");
        return await _client.GetFromJsonAsync<PiPoint>(
            $"points?path={path}&selectedFields=WebId;Name;Path;PointType",
            PiJsonContext.Default.PiPoint, ct);
    }

    public async Task<TimedValue?> GetCurrentValueAsync(
        string webId, CancellationToken ct = default)
    {
        return await _client.GetFromJsonAsync<TimedValue>(
            $"streams/{webId}/value?selectedFields=Value;Timestamp;Good;UnitsAbbreviation",
            PiJsonContext.Default.TimedValue, ct);
    }

    public async Task<TimedValueList?> GetRecordedValuesAsync(
        string webId, string startTime, string endTime,
        int maxCount = 1000, CancellationToken ct = default)
    {
        var url = $"streams/{webId}/recorded" +
            $"?startTime={Uri.EscapeDataString(startTime)}" +
            $"&endTime={Uri.EscapeDataString(endTime)}" +
            $"&maxCount={maxCount}" +
            $"&selectedFields=Items.Value;Items.Timestamp;Items.Good;Links";
        return await _client.GetFromJsonAsync<TimedValueList>(
            url, PiJsonContext.Default.TimedValueList, ct);
    }

    public async Task WriteValueAsync(
        string webId, object value, CancellationToken ct = default)
    {
        var payload = new { Value = value, Timestamp = DateTimeOffset.UtcNow };
        var response = await _client.PostAsJsonAsync(
            $"streams/{webId}/value", payload, ct);
        response.EnsureSuccessStatusCode();
    }
}

Why IHttpClientFactory matters

Creating HttpClient directly in a loop or per-request leads to socket exhaustion under load. IHttpClientFactory manages the underlying HttpMessageHandler lifecycle, pools connections, and respects DNS TTL. It also integrates with Polly for retry and circuit-breaker policies.

Quick start: connect and authenticate

For console apps and scripts, you can use HttpClient directly. This is the simplest way to get started.

QuickStart.cscsharp
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;

// Use your current Windows credentials (Kerberos/NTLM)
var handler = new HttpClientHandler
{
    Credentials = CredentialCache.DefaultNetworkCredentials,
    // Testing only -- in production, trust your CA cert properly
    ServerCertificateCustomValidationCallback =
        HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};

using var client = new HttpClient(handler)
{
    BaseAddress = new Uri("https://your-server/piwebapi/"),
    Timeout = TimeSpan.FromSeconds(30),
};

// Test the connection
var system = await client.GetFromJsonAsync<JsonElement>("system");
Console.WriteLine($"Connected to: {system.GetProperty("ProductTitle")}");
Console.WriteLine($"Version: {system.GetProperty("ProductVersion")}");

Certificate handling in production

Do not use DangerousAcceptAnyServerCertificateValidator in production. Install your organization's CA certificate in the Windows certificate store, or provide a custom validator that checks the specific thumbprint.

Basic auth alternative

If your PI Web API server accepts Basic authentication:

BasicAuth.cscsharp
using System.Net.Http.Headers;
using System.Text;

using var client = new HttpClient(handler)
{
    BaseAddress = new Uri("https://your-server/piwebapi/"),
};

// Read credentials from environment or config -- never hardcode
var username = Environment.GetEnvironmentVariable("PI_USERNAME")!;
var password = Environment.GetEnvironmentVariable("PI_PASSWORD")!;
var credentials = Convert.ToBase64String(
    Encoding.ASCII.GetBytes($"{username}:{password}"));

client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Basic", credentials);

Read current value with digital state handling

PI points can hold numeric values, strings, or digital states. Digital states come back as JSON objects (not numbers), so you need to handle them explicitly to avoid runtime exceptions.

ReadCurrentValue.cscsharp
// Look up a PI point by path
var path = Uri.EscapeDataString("\\\\SERVER\\sinusoid");
var point = await client.GetFromJsonAsync<PiPoint>(
    $"points?path={path}&selectedFields=WebId;Name;PointType",
    PiJsonContext.Default.PiPoint);

// Read the current value
var value = await client.GetFromJsonAsync<JsonElement>(
    $"streams/{point!.WebId}/value?selectedFields=Value;Timestamp;Good;UnitsAbbreviation");

// Handle the Value field -- it could be a number, string, or digital state object
var rawValue = value.GetProperty("Value");
var timestamp = value.GetProperty("Timestamp").GetString();
var good = value.GetProperty("Good").GetBoolean();

string displayValue = rawValue.ValueKind switch
{
    JsonValueKind.Number => rawValue.GetDouble().ToString("F3"),
    JsonValueKind.String => rawValue.GetString() ?? "null",
    JsonValueKind.Object => rawValue.TryGetProperty("Name", out var name)
        ? $"[Digital: {name.GetString()}]"   // Digital state
        : rawValue.ToString(),
    _ => rawValue.ToString(),
};

Console.WriteLine($"Value: {displayValue}");
Console.WriteLine($"Timestamp: {timestamp}");
Console.WriteLine($"Good: {good}");
if (value.TryGetProperty("UnitsAbbreviation", out var units))
    Console.WriteLine($"Units: {units.GetString()}");

Read recorded values with pagination check

ReadRecordedValues.cscsharp
// Read last 24 hours of recorded values with selectedFields
var url = $"streams/{point!.WebId}/recorded" +
    "?startTime=*-24h&endTime=*&maxCount=1000" +
    "&selectedFields=Items.Value;Items.Timestamp;Items.Good;Links";

var response = await client.GetFromJsonAsync<TimedValueList>(
    url, PiJsonContext.Default.TimedValueList);

// Check for silent truncation
if (response?.Links?.ContainsKey("Next") == true)
{
    Console.WriteLine($"WARNING: Data truncated at {response.Items.Count} values. " +
        "Increase maxCount or use time-based pagination.");
}

foreach (var item in response!.Items)
{
    // Skip bad quality values
    if (!item.Good) continue;

    Console.WriteLine($"{item.Timestamp:O}: {item.Value}");
}

Write a value with cancellation

WriteValue.cscsharp
using System.Net.Http.Json;

// CancellationToken allows the caller to abort the write
// (important for long-running services and web request handlers)
async Task WriteValueAsync(HttpClient client, string webId,
    double value, CancellationToken ct = default)
{
    var payload = new
    {
        Value = value,
        Timestamp = DateTimeOffset.UtcNow.ToString("o"),
    };

    var response = await client.PostAsJsonAsync(
        $"streams/{webId}/value", payload, ct);

    if (!response.IsSuccessStatusCode)
    {
        var body = await response.Content.ReadAsStringAsync(ct);
        throw new HttpRequestException(
            $"PI Web API write failed: HTTP {(int)response.StatusCode} - {body}");
    }
}

// Usage with a timeout
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
await WriteValueAsync(client, point!.WebId, 42.0, cts.Token);
Console.WriteLine("Value written successfully.");

Batch request with typed error handling

Batch requests let you send multiple API calls in a single HTTP request, reducing round trips. Always check each sub-request status individually -- the batch can return 200 overall while sub-requests fail.

BatchRequest.cscsharp
using System.Text.Json;

async Task<Dictionary<string, JsonElement>> BatchReadCurrentValues(
    HttpClient client, IReadOnlyList<string> webIds,
    CancellationToken ct = default)
{
    var batch = new Dictionary<string, object>();
    for (int i = 0; i < webIds.Count; i++)
    {
        batch[$"read_{i}"] = new
        {
            Method = "GET",
            Resource = $"{client.BaseAddress}streams/{webIds[i]}/value" +
                "?selectedFields=Value;Timestamp;Good",
        };
    }

    var response = await client.PostAsJsonAsync("batch", batch, ct);
    response.EnsureSuccessStatusCode();

    var results = await response.Content
        .ReadFromJsonAsync<Dictionary<string, JsonElement>>(ct);

    // Check each sub-request
    var values = new Dictionary<string, JsonElement>();
    foreach (var (key, result) in results!)
    {
        var status = result.GetProperty("Status").GetInt32();
        if (status >= 400)
        {
            Console.Error.WriteLine($"{key}: HTTP {status}");
            continue;
        }
        values[key] = result.GetProperty("Content");
    }

    return values;
}

// Usage: read up to 100 points in a single HTTP request
var webIds = new List<string> { point!.WebId /* , ... more WebIDs */ };
var currentValues = await BatchReadCurrentValues(client, webIds);

When to use .NET vs Python

ScenarioBest fitWhy
Data science / analyticsPythonpandas, numpy, Jupyter ecosystem
Windows Service or background job.NETWorker Service template, native Windows auth, system tray integration
Quick scripting or prototypingPythonFaster iteration, less boilerplate
ASP.NET web application.NETSame stack, shared DI, config, and auth pipeline
Azure Functions / AWS Lambda.NET or Python.NET has faster cold start; Python has simpler packaging
ETL to a databaseEitherDepends on existing infrastructure and team skills
High-throughput pipeline.NETBetter async concurrency, lower GC pressure with value types
Cross-platform (Linux/Mac)Python or .NET 8+.NET 8 runs on Linux, but Kerberos setup is harder outside Windows

Next steps