Channel-based streaming client for the Home Connect API, built on Akka.Streams and TurboHTTP.
Follows the Turbo Client Pattern: commands flow into a channel, responses and events flow back out through another channel, and the materialized Akka.Streams flow is owned by an actor for clean lifecycle management.
- .NET 10 (TurboHTTP requires it)
- A Home Connect developer account — register at https://developer.home-connect.com/
dotnet add package TurboHomeConnectThe sample app ships with a Dockerfile + docker-compose.yml + .env. Three steps:
- Register an app at https://developer.home-connect.com (one for the simulator is enough to start). Pick
http://localhost:5099/oauth/callbackas a redirect URI. - Copy your client id/secret into
.env:cp .env.example .env $EDITOR .env # fill HOMECONNECT_CLIENT_ID and HOMECONNECT_CLIENT_SECRET
- Run it:
docker compose up --build
The sample prints the authorize URL on first run — click it, authorize in your browser, the redirect lands back on the container's /oauth/callback, and the refresh token is persisted to ./.tokens/token.json on the host. Subsequent runs skip the interactive step.
.env knobs:
| Variable | Purpose |
|---|---|
HOMECONNECT_CLIENT_ID / _SECRET |
From the developer portal. |
HOMECONNECT_USE_SIMULATOR |
1 for simulator, 0 for production. |
HOMECONNECT_OPEN_BROWSER |
0 for headless — URL is printed instead. |
HOMECONNECT_TOKEN_FILE |
Where to persist tokens (inside the container, mounted as a volume). |
using Akka.Actor;
using TurboHomeConnect;
using TurboHomeConnect.Commands;
using var system = ActorSystem.Create("home-connect");
using var client = HomeConnectBuilder.Create()
.UseProduction() // or .UseSimulator()
.StaticAccessToken(myAccessToken) // or .TokenProvider(...)
.Build(system);
var appliances = await client.RequestAsync(new GetAppliancesCommand());
foreach (var a in appliances.Appliances) Console.WriteLine($"{a.HaId} — {a.Type}");var status = await client.RequestAsync(new GetStatusCommand(haId));
foreach (var s in status.Status) Console.WriteLine($"{s.Key} = {s.Value}");RequestAsync returns the concrete typed response (StatusResponse, AppliancesResponse, etc.) — no casting or pattern matching needed. On HTTP errors it throws HomeConnectApiException with StatusCode, ErrorKey, and CorrelationId.
await client.SendAsync(new SetSettingCommand(haId, "BSH.Common.Setting.PowerState",
JsonSerializer.SerializeToElement("BSH.Common.EnumType.PowerState.On")));
await foreach (var msg in client.Responses.ReadAllAsync())
{
if (msg is SettingUpdatedResponse u) Console.WriteLine($"set {u.SettingKey}");
}await client.SendAsync(new SubscribeEventsCommand()); // or scope to one haId
await foreach (var msg in client.Responses.ReadAllAsync())
{
switch (msg)
{
case HomeConnectEventMessage e:
foreach (var item in e.Items) Console.WriteLine($"{e.HaId}: {item.Key} = {item.Value}");
break;
case SubscriptionDisconnectedMessage drop:
Console.WriteLine($"reconnecting after: {drop.Reason?.Message}");
break;
}
}The SSE stream is wrapped in RestartSource.WithBackoff, so transient disconnects reconnect automatically with backoff. Tune via .SseRestartSettings(...) on the builder.
Use any OAuth library you like (e.g. Duende.IdentityModel) and hand the builder a function that returns a current access token. The function is called for every REST request and every (re)subscribe, so it's the right place to refresh on expiry.
.TokenProvider(ct => myAuthClient.GetAccessTokenAsync(ct))using var oauth = new HomeConnectAuthorizationCodeFlow(new HomeConnectOAuthOptions
{
ClientId = "...",
ClientSecret = "...",
RedirectUri = new Uri("http://localhost:5099/oauth/callback"),
TokenStore = new InMemoryTokenStore(), // implement IPersistedTokenStore for disk-backed
});
await oauth.AuthorizeInteractiveAsync(); // launches browser once
// then plug in:
.TokenProvider(oauth.GetAccessTokenAsync)The helper hosts a local HttpListener for the OAuth callback and refreshes tokens automatically.
| Builder method | What it does | Default |
|---|---|---|
UseProduction() / UseSimulator() |
Sets base address. | (required) |
BaseAddress(uri) |
Custom base address (e.g. test proxy). | — |
TokenProvider(func) |
Bearer token source. Called per request and per (re)connect. | (required) |
StaticAccessToken(s) |
Convenience for short-lived demos. | — |
UseHttpClient(client) |
Bring your own ITurboHttpClient (DI-friendly). |
internal |
ConfigureHttpClient(action) |
Tweak the internal TurboClientOptions. |
defaults |
LoggerFactory(factory) |
Bridges Akka logs to MEL. | — |
CommandCapacity(n) |
Bounded input channel capacity. | 64 |
RestParallelism(n) |
In-flight REST requests. | 4 |
MaxConcurrentSubscriptions(n) |
Parallel SSE streams. | 4 |
SseRestartSettings(s) |
Backoff + max-restart policy for SSE. | 1s/60s/0.2 |
public sealed record GetFridgeDoorStateCommand(string HaId) : RestCommand<FridgeDoorResponse>
{
protected override HttpRequestMessage BuildRequest()
=> RestHelpers.Get($"api/homeappliances/{HaId}/status/Refrigeration.Common.Status.Door");
protected override async Task<FridgeDoorResponse> MapResponseAsync(
HttpResponseMessage response, CancellationToken ct)
{
var data = await RestHelpers.ReadDataAsync(response, MyJsonContext.Default.DataEnvelopeDoorState, ct);
return new FridgeDoorResponse(CorrelationId, HaId, data.IsOpen);
}
}RestHelpers and DataEnvelope<T> are public — reuse the BSH media type plumbing rather than reimplementing it.
The client exposes both channel endpoints for direct stream composition:
// feed commands from a custom Akka.Streams graph
mySource.RunWith(ChannelSink.FromWriter(client.Commands, isOwner: false), materializer);
// pump all messages into an actor
ChannelSource.FromReader(client.Responses)
.RunWith(Sink.ActorRefWithBackpressure<IHomeConnectMessage>(
myActor, initMessage, ackMessage, completeMessage, ex => new StreamFailed(ex)),
materializer);Two caveats: responses claimed by a pending RequestAsync are intercepted and never appear on Responses; multiple Responses readers compete (each message goes to exactly one reader).
user code channels StreamOwnerActor
───────── ──────── ─────────────────
SendAsync(cmd) ─────► commandChannel ─────► Partition ┬─► RestFlow ──► HttpClient ─┐
│ │
└─► SseFlow ──► SseSource ─┤
▼
client.Responses ◄── userChannel ◄── Dispatch ◄── streamOutput ◄── Merge ◄── messages
│
┌───────┴───────┐
│ correlated? │
└───┬───────┬───┘
yes │ │ no
▼ ▼
pending TCS user channel
│ │
▼ ▼
RequestAsync() Responses.ReadAllAsync()
See turbo-client-pattern.md for the full pattern this library implements.
REST endpoints covered:
- Appliances: list, get
- Status: list, single
- Settings: list, single, set
- Programs: active (get/start/stop), selected (get/select), available (list/get)
- Program options: active/selected (list/get/set)
- Commands: list, execute
- Images: list, fetch bytes
Plus the SSE /events stream — both per-appliance and account-wide.
MIT