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
| Concern | Primitive | Page |
|---|---|---|
| Outbound auth | AddOutboundOAuth2Client<T>(name) | Outbound Integrations |
| Inbound webhook signature | IWebhookSignatureScheme + HmacSha256TimestampScheme | Inbound Webhooks |
| Inbound webhook idempotency | IWebhookIdempotencyStore | Inbound Webhooks |
| Outbound webhook registration | WebhookRegistrarBase | Outbound Webhook Registration |
| Polling + reconcile against twin | ReconcilingPoller<T> | Reconciling Poller |
| Read current twin state | AdapterContext.Twin (ITwinReader) | Twin Reader |
| Async upstream → sync method | AdapterContext.AsyncActions.PollUntilTerminalAsync | Adapter Interface |
| Map upstream id → BT device | IDeviceRepository.FindByExternalIdAsync | External Device Id |
| Upstream-API health check | services.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, …):
- Caller writes
desired.chargingMode = "START"on the twin. - Adapter's
SendDesiredPropertiesAsyncpushes the change to the upstream API (EnodePOST /chargers/{id}/charging). - The poller observes the upstream state change and writes
reported.chargingMode = "STARTED". - Caller observes the reported update via
device.state-changedCloudEvent 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:
- User adds upstream account in the BT mobile app.
- App lists upstream devices via the upstream API.
- For each device the user wants in BT, app creates:
- A providerClient (the SDK device record) with
externalIdset to the upstream id. - An asset with a specific DTDL
modelId. - A binding linking them.
- A providerClient (the SDK device record) with
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.cs—SendDesiredPropertiesAsyncmaps desired changes to upstream actions;SendCommandAsyncreturns 405;IsDeviceConnectedfollows whatever the diagnostics layer last reported.EnodePoller.cs— derives fromReconcilingPoller<Item>.EnodeWebhookController.cs— verifies signature, dedupes, dispatches.EnodeWebhookRegistrar.cs— derives fromWebhookRegistrarBase.