HelinData.Devices
1.0.1
dotnet add package HelinData.Devices --version 1.0.1
NuGet\Install-Package HelinData.Devices -Version 1.0.1
<PackageReference Include="HelinData.Devices" Version="1.0.1" />
<PackageVersion Include="HelinData.Devices" Version="1.0.1" />
<PackageReference Include="HelinData.Devices" />
paket add HelinData.Devices --version 1.0.1
#r "nuget: HelinData.Devices, 1.0.1"
#:package HelinData.Devices@1.0.1
#addin nuget:?package=HelinData.Devices&version=1.0.1
#tool nuget:?package=HelinData.Devices&version=1.0.1
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_configurationtakes 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 aconfigurationkey alongside routing fields (target_module_*). The SDK unwrapsconfiguration, hands your handler just that, and wraps your returned config back underconfiguration:{ "status": 200, "response": { "configuration": <your config> } }. A payload with noconfigurationobject yields400.get_healthtakes no input; your handler returns aHealthMetricsResponse— a flat list of named samples, each with aDatetimeand an integerValue. 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.SnakeCaseLowermaps 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, missingrequiredmembers, the wrong shape, ornullall produce aBadRequest(400) instead of a silently-empty object. Mark mandatory propertiesrequired.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.JsonElementto receive the raw configurationJsonElementand 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 | Versions 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. |
-
net10.0
- Microsoft.Extensions.DependencyInjection (>= 10.0.11)
- Microsoft.Extensions.Logging (>= 10.0.11)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.11)
- Microsoft.Extensions.Logging.Console (>= 10.0.11)
- MQTTnet (>= 5.2.0.1603)
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 |