Skip to main content

Building an Aggregator Provider

Walks through writing a provider that integrates a cloud aggregator (Enode, OEM clouds, Tibber, etc.) as opposed to a transport-owning provider (MQTT, AMQP). Aggregator providers share a common shape:

  • OAuth2 client-credentials to call the third party
  • A reconciling poller to pull state on a cadence
  • Inbound webhooks for push events
  • Property convergence (not Direct Methods) for state-change actions
  • Upstream-owned identifiers via externalId

A worked end-to-end example lives at examples/BeaconTower.EnodeProvider/ in the mqttprovider repo.

The shape

                      ┌─────────────────┐
│ Mobile app │ user adds Enode account,
│ (provisioning) │ creates BT providerClient
└────────┬────────┘ + asset + binding with
│ externalId set

┌─────────────────┐
│ BT (your) │
│ Enode-backed │
│ provider │
│ │
│ Poller ──────┐ │ desired: chargingMode = "START"
│ Webhook ─────┼──┼──── ▶ Enode REST API
│ Adapter ◀────┘ │
└─────────────────┘

What the SDK provides

ConcernPrimitivePage
Outbound authAddOutboundOAuth2Client<T>(name)Outbound Integrations
Inbound webhook signatureIWebhookSignatureScheme + HmacSha256TimestampSchemeInbound Webhooks
Inbound webhook idempotencyIWebhookIdempotencyStoreInbound Webhooks
Outbound webhook registrationWebhookRegistrarBaseOutbound Webhook Registration
Polling + reconcile against twinReconcilingPoller<T>Reconciling Poller
Read current twin stateAdapterContext.Twin (ITwinReader)Twin Reader
Async upstream → sync methodAdapterContext.AsyncActions.PollUntilTerminalAsyncAdapter Interface
Map upstream id → BT deviceIDeviceRepository.FindByExternalIdAsyncExternal Device Id
Upstream-API health checkservices.AddUpstreamHealthCheck<T>(name)Health Checks

Async state changes — property convergence, not commands

The canonical BT pattern for aggregator-style async actions (start-charging, set-temperature, …):

  1. Caller writes desired.chargingMode = "START" on the twin.
  2. Adapter's SendDesiredPropertiesAsync pushes the change to the upstream API (Enode POST /chargers/{id}/charging).
  3. The poller observes the upstream state change and writes reported.chargingMode = "STARTED".
  4. Caller observes the reported update via device.state-changed CloudEvent or twin polling.

Direct methods (SendCommandAsync) are only for synchronous sub-30s round-trips — reboot, get-diagnostics, ping. Anything that takes longer than the direct-method timeout MUST be modeled as property convergence.

Provisioning is mobile-app driven

The adapter does NOT auto-discover devices from the upstream. The canonical flow:

  1. User adds upstream account in the BT mobile app.
  2. App lists upstream devices via the upstream API.
  3. For each device the user wants in BT, app creates:
    • A providerClient (the SDK device record) with externalId set to the upstream id.
    • An asset with a specific DTDL modelId.
    • A binding linking them.

The adapter trusts that externalId is set on the device record; inbound webhooks and reconcile cycles use IDeviceRepository.FindByExternalIdAsync to map back.

Items returned by the upstream that have no matching BT device (e.g. before the user has added them) are silently skipped by the poller.

Device-type modeling — DTDL, not adapter capabilities

If your provider serves multiple device kinds (chargers, vehicles, batteries), each kind gets its own published DTDL model. The asset's modelId is the discriminator. The adapter doesn't need a kind field on the device record — the model carries that information.

Skeleton

// Program.cs
builder.Services.AddOutboundOAuth2Client<EnodeApiClient>(
builder.Configuration, "enode")
.ConfigureHttpClient((sp, http) =>
{
http.BaseAddress = new Uri("https://enode-api.sandbox.enode.io");
});

builder.Services.AddSingleton<IWebhookSignatureScheme>(_ => new HmacSha256TimestampScheme());
builder.Services.AddSingleton<IWebhookIdempotencyStore>(_ => new InMemoryWebhookIdempotencyStore());

builder.Services.AddBeaconTowerProvider<EnodeAdapter>(builder, options =>
{
options.ProviderId = "enode-provider";
});

builder.Services.AddHostedService<EnodeWebhookRegistrar>();
builder.Services.AddHostedService<EnodePoller>();

builder.Services.AddControllers();

The Enode probe is the canonical reference. Read it top-to-bottom in under 20 minutes:

  • EnodeApiClient.cs — OAuth handler does the auth; the client is just a typed wrapper.
  • EnodeAdapter.csSendDesiredPropertiesAsync maps desired changes to upstream actions; SendCommandAsync returns 405; IsDeviceConnected follows whatever the diagnostics layer last reported.
  • EnodePoller.cs — derives from ReconcilingPoller<Item>.
  • EnodeWebhookController.cs — verifies signature, dedupes, dispatches.
  • EnodeWebhookRegistrar.cs — derives from WebhookRegistrarBase.