HelinData.Devices 1.0.1

dotnet add package HelinData.Devices --version 1.0.1
                    
NuGet\Install-Package HelinData.Devices -Version 1.0.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="HelinData.Devices" Version="1.0.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="HelinData.Devices" Version="1.0.1" />
                    
Directory.Packages.props
<PackageReference Include="HelinData.Devices" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add HelinData.Devices --version 1.0.1
                    
#r "nuget: HelinData.Devices, 1.0.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package HelinData.Devices@1.0.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=HelinData.Devices&version=1.0.1
                    
Install as a Cake Addin
#tool nuget:?package=HelinData.Devices&version=1.0.1
                    
Install as a Cake Tool

HELIN-Edge-SDK-csharp

.NET 10 SDK for receiving Helin Platform direct method calls over raw MQTT — wire-compatible with HELIN-Edge-SDK-python v0.1.1.

dotnet add package HelinData.Devices

Overview

Allows Azure IoT Edge modules written in C# to receive Helin Platform configuration calls without depending on the Python runtime. The SDK handles the full lifecycle:

  • Connect to edgeHub (or a local MQTT broker for testing), with automatic reconnect on unexpected disconnects
  • Authenticate with a SAS token (workload API in production, local HMAC key or anonymous for testing)
  • Subscribe to IoT Hub direct method topics
  • Receive, decompress, and dispatch requests to strongly-typed handlers
  • Serialize, compress, and publish responses back

You register a callback per method on an IEdgeModuleClient, then call RunAsync. Callbacks are strongly typed: define a plain C# class for your configuration and the SDK deserializes the request into it and serializes your result back — no manual JSON parsing.

For the platform-side view — how these calls are triggered and what the Platform API expects — see Configure your module at runtime.

Quick Start

The platform-demo/ModuleExample project is the canonical example. It uses the .NET Generic Host with dependency injection.

Program.cs — register the SDK and a hosted service:

using HelinData.Devices.Config;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

builder.Services.AddHelinEdgeSdk();
builder.Services.AddHostedService<ConfigurationHandler>();

var app = builder.Build();
await app.StartAsync();

The parameterless AddHelinEdgeSdk() reads the IOTEDGE_* env vars injected by the Azure IoT Edge runtime and signs SAS tokens via the workload API — that is all a production module needs.

MyConfig.cs — your configuration model. Mark mandatory properties required so a payload that omits them is rejected:

public sealed class MyConfig
{
    public required string OpcuaIp { get; set; }
    public required int OpcuaPort { get; set; }
}

ConfigurationHandler.cs — implement your handlers against the injected IEdgeModuleClient:

using HelinData.Devices;
using HelinData.Devices.Responses;
using Microsoft.Extensions.Hosting;

public class ConfigurationHandler : IHostedService
{
    private readonly IEdgeModuleClient _edgeModuleClient;
    private MyConfig _currentConfiguration;

    public ConfigurationHandler(IEdgeModuleClient edgeModuleClient)
    {
        _currentConfiguration = new MyConfig { OpcuaIp = "127.0.0.1", OpcuaPort = 4840 };
        _edgeModuleClient = edgeModuleClient;

        // Strongly-typed handlers: MyConfig maps to/from the snake_case wire JSON automatically
        // (OpcuaIp <-> "opcua_ip", OpcuaPort <-> "opcua_port").
        _edgeModuleClient.OnGetConfiguration<MyConfig>(() =>
        {
            return (ResponseStatus.Ok, _currentConfiguration);
        });

        _edgeModuleClient.OnSetConfiguration<MyConfig>(config =>
        {
            _currentConfiguration = config;
            Console.WriteLine($"Configuration updated: {config.OpcuaIp}:{config.OpcuaPort}");
            return (ResponseStatus.Ok, _currentConfiguration);
        });

        // Health metrics: a flat list of named integer samples describing the module's state.
        _edgeModuleClient.OnGetHealth(() =>
        {
            var sampledAt = DateTime.UtcNow;
            return (ResponseStatus.Ok, new HealthMetricsResponse
            {
                Metrics =
                [
                    new HealthMetric { Name = "opcua_connected", Datetime = sampledAt, Value = 1 },
                    new HealthMetric { Name = "opcua_port", Datetime = sampledAt, Value = _currentConfiguration.OpcuaPort }
                ]
            });
        });
    }

    public Task StartAsync(CancellationToken ct) => _edgeModuleClient.RunAsync(ct);
    public Task StopAsync(CancellationToken ct) => _edgeModuleClient.StopAsync();
}

Alternative — a custom connection configuration

Pass a factory when the module has to choose its own connection, for example to fall back to a local broker during development. This is what ModuleExample does so the demo can run against mosquitto:

builder.Services.AddHelinEdgeSdk(() =>
{
    // Production: read the IOTEDGE_* env vars injected by the Azure IoT Edge runtime.
    if (Environment.GetEnvironmentVariable("IOTEDGE_IOTHUBHOSTNAME") is not null)
        return ConnectionConfiguration.FromEdgeEnvironmentAsync().GetAwaiter().GetResult();

    // Local testing: anonymous MQTT broker.
    var host = Environment.GetEnvironmentVariable("MQTT_HOST") ?? "localhost";
    var port = int.Parse(Environment.GetEnvironmentVariable("MQTT_PORT") ?? "1883");
    return ConnectionConfiguration.Manual(host, port);
});

An AddHelinEdgeSdk(ConnectionConfiguration) overload takes an already-built configuration if you have one in hand.

Without dependency injection

If you are not using the Generic Host, build the client directly with a logger factory:

using HelinData.Devices.Config;

var client = ConfigurationModule.GetEdgeModuleClient(
    ConnectionConfiguration.Manual("localhost"), loggerFactory);

client.OnGetConfiguration<MyConfig>(() => (ResponseStatus.Ok, currentConfig));
client.OnSetConfiguration<MyConfig>(config => (ResponseStatus.Ok, config));

await client.RunAsync(cancellationToken);

ConfigurationModule.GetEdgeModuleClient() and GetEdgeModuleClient(ILoggerFactory) overloads are also available; both read the IOTEDGE_* environment (production).

Supported Methods

Method name Callback Request payload (decompressed)
get_configuration OnGetConfiguration<T> {} (no input)
set_configuration OnSetConfiguration<T> { "target_module_hostname": …, "target_module_port": …, "configuration": { … } }
get_health OnGetHealth {} (no input)

These are the same three callbacks described in Configure your module at runtime, which documents how the platform triggers them.

  • get_configuration takes no input; your handler returns the current configuration. The response envelope is { "status": 200, "response": <your config> }.

  • set_configuration — the platform nests your config under a configuration key alongside routing fields (target_module_*). The SDK unwraps configuration, hands your handler just that, and wraps your returned config back under configuration: { "status": 200, "response": { "configuration": <your config> } }. A payload with no configuration object yields 400.

  • get_health takes no input; your handler returns a HealthMetricsResponse — a flat list of named samples, each with a Datetime and an integer Value. The body is not wrapped:

    {"status": 200, "response": {"metrics": [{"name": "opcua_connected", "datetime": "2026-08-04T12:30:00Z", "value": 1}]}}
    

A method the platform calls but you never registered a handler for answers 404 — so a module that does not implement OnGetHealth still responds, rather than timing out. The status your handler returns is reported in both the response topic and the envelope's status field, so returning e.g. ResponseStatus.InternalServerError from OnGetHealth marks the module unhealthy without throwing.

Strongly-Typed Configuration

The generic OnGetConfiguration<T> / OnSetConfiguration<T> handlers (de)serialize automatically:

  • snake_case mapping — JsonNamingPolicy.SnakeCaseLower maps PascalCase C# properties to the platform's snake_case keys (OpcuaIp ↔ opcua_ip). Reads are case-insensitive; [JsonPropertyName] overrides the policy per property.

  • Strict deserialization — the incoming configuration must match T. Unknown/extra fields, missing required members, the wrong shape, or null all produce a BadRequest (400) instead of a silently-empty object. Mark mandatory properties required.

  • Routing fields ignored — the platform's target_module_* fields are stripped before your handler runs; your type never needs to declare them.

  • Raw JSON escape hatch — use T = System.Text.Json.JsonElement to receive the raw configuration JsonElement and handle the JSON yourself:

    client.OnSetConfiguration<JsonElement>(cfg =>
    {
        var ip = cfg.GetProperty("opcua_ip").GetString();
        return (ResponseStatus.Ok, cfg);
    });
    

Exception → Status Code Mapping

Throw from your handler to return an error status; the SDK maps exceptions to a response status:

C# Exception Status
ArgumentException / ArgumentNullException 400
InvalidOperationException 400
UnknownMethodException 400
FileNotFoundException 404
ConfigurationFailedException 409 (or its StatusCode)
Any other Exception 500

ConfigurationFailedException (in HelinData.Devices.Errors) also carries CurrentConfiguration — the module's last known valid state — which is included in the response envelope for recovery.

Connection Modes

ConnectionConfiguration (in HelinData.Devices.Config) provides two factories.

Production — FromEdgeEnvironmentAsync()

Reads the IOTEDGE_* env vars injected by the Azure IoT Edge runtime and signs SAS tokens via the workload API (no private key on disk):

Env Var Purpose
IOTEDGE_IOTHUBHOSTNAME IoT Hub hostname
IOTEDGE_DEVICEID Device ID
IOTEDGE_MODULEID Module ID
IOTEDGE_GATEWAYHOSTNAME edgeHub host (MQTT broker)
IOTEDGE_MODULEGENERATIONID Workload signing path
IOTEDGE_WORKLOADURI Workload API socket / HTTP endpoint
IOTEDGE_APIVERSION Workload API version

TLS uses the edgeHub trust bundle from the workload API (the full PEM chain is loaded, not just the leaf).

Local — Manual(...)

For local development. Anonymous, or HMAC-SHA256 from a connection string:

// Anonymous, plaintext broker
var config = ConnectionConfiguration.Manual("localhost");

// With a connection string (SAS auth)
var config = ConnectionConfiguration.Manual("localhost",
    connectionString: "HostName=hub.azure-devices.net;DeviceId=dev1;ModuleId=mod1;SharedAccessKey=...");

Testing

Unit tests (no external dependencies)

dotnet test tests/HelinEdgeSdk.Tests --filter "Category=Unit"

Integration tests (require an MQTT broker)

Start a broker, then run the integration category:

docker compose -f docker/docker-compose.demo.yml up -d mosquitto
dotnet test tests/HelinEdgeSdk.Tests --filter "Category=Integration"

Integration tests are automatically skipped if no broker is reachable on localhost:1883.

Full suite in Docker (CI)

Builds the SDK + tests and runs the whole suite against a throwaway broker:

docker compose -f docker/docker-compose.ci.yml up --build --abort-on-container-exit --exit-code-from tests
docker compose -f docker/docker-compose.ci.yml down --remove-orphans

Demo (Docker)

Runs ModuleExample against a local broker:

docker compose -f docker/docker-compose.demo.yml up --build

The module connects to the mosquitto service and serves get_configuration, set_configuration, and get_health.

Wire Protocol

All payloads use a gzip+base64 transport envelope — byte-for-byte compatible with the Helin Platform:

{"encoding": "gzip", "data": "<base64(gzip(minified_json))>"}

MQTT topics follow the Azure IoT Hub Direct Method format:

  • Request: $iothub/methods/POST/{method}/?$rid={rid}
  • Response: $iothub/methods/res/{status}/?$rid={rid}

Project Structure

src/HelinEdgeSdk/          — SDK class library (.NET 10)
tests/HelinEdgeSdk.Tests/  — Unit + integration tests (xUnit)
platform-demo/ModuleExample/ — Example module (Generic Host + DI)
docker/                    — mosquitto broker + CI / demo compose files

Versioning

The package version is derived from the commit history by GitVersion (see GitVersion.yml) — it is not hand-written in the csproj. It is computed and packed in the pipeline's build stage; the publish stage only pushes the resulting artifact. Every commit on main publishes a new patch version. To bump minor or major, add a +semver: minor (or +semver: major) line to a commit message, or tag the commit.

Once a package is on nuget.org, the pipeline tags that commit with its version (e.g. 1.0.4, matching the existing unprefixed tags). Each release is therefore the version source for the commits after it, and the repository tags are a record of what was actually published. Re-running a build for an already-released commit leaves the existing tag untouched.

Off main, the pre-release number is the commit count on the branch, so every new commit yields a new version: 1.1.0-my-feature.1, 1.1.0-my-feature.2, and so on. Pre-release packages are built but not pushed — only main publishes.

Wire-compatibility is tracked separately: this SDK is wire-compatible with HELIN-Edge-SDK-python v0.1.1, which is unrelated to this package's own version number.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.1 116 8/13/2026