Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Net;

namespace NetDaemon.Client.Internal.Exceptions;

[SuppressMessage("", "RCS1194")]
public class HomeAssistantApiCallException : ApplicationException
{
public HttpStatusCode Code { get; private set; }
public HomeAssistantApiCallException(string? message, HttpStatusCode code) : base(message)
{
Code = code;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ public interface IHomeAssistantConnection : IHomeAssistantApiManager, IAsyncDisp
Task SendCommandAsync<T>(T command, CancellationToken cancelToken) where T : CommandMessage;

/// <summary>
/// Sends a command message to Home Assistant without handling the result
/// Sends a command message to Home Assistant and return the result
/// </summary>
/// <param name="command">Command message to send</param>
/// <param name="cancelToken">token to cancel operation</param>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace NetDaemon.Client.Internal.HomeAssistant.Commands;

internal record CreateHelperCommandBase : CommandMessage
{
public CreateHelperCommandBase(string helperType)
{
Type = $"{helperType}/create";
}
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("icon")] public string Icon { get; init; } = string.Empty;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using NetDaemon.Client.Internal.Exceptions;

namespace NetDaemon.Client.Internal;

internal class HomeAssistantApiManager : IHomeAssistantApiManager
Expand Down Expand Up @@ -39,8 +41,8 @@ HttpClient httpClient
.ConfigureAwait(false);
}

throw new ApplicationException(
$"Call to API unsuccessful, code {result.StatusCode}: reason: {result.ReasonPhrase}");
throw new HomeAssistantApiCallException(
$"Call to API unsuccessful, code {result.StatusCode}: reason: {result.ReasonPhrase}", result.StatusCode);
}

public async Task<T?> PostApiCallAsync<T>(string apiPath, CancellationToken cancelToken, object? data = null)
Expand Down
369 changes: 185 additions & 184 deletions src/Runtime/NetDaemon.Runtime.Tests/Internal/AppStateManagerTests.cs

Large diffs are not rendered by default.

118 changes: 62 additions & 56 deletions src/Runtime/NetDaemon.Runtime/Internal/AppStateManager.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Net;
using System.Reactive.Linq;
using System.Text;
using NetDaemon.AppModel;
using NetDaemon.HassModel.Common;
using NetDaemon.HassModel.Integration;
using NetDaemon.Client.Common.HomeAssistant.Model;
using NetDaemon.Client.Internal.Exceptions;

namespace NetDaemon.Runtime.Internal;

internal class AppStateManager : IAppStateManager, IHandleHomeAssistantAppStateUpdates
internal class AppStateManager : IAppStateManager, IHandleHomeAssistantAppStateUpdates, IDisposable
{
private readonly CancellationTokenSource _cancelTokenSource = new();
private readonly IServiceProvider _provider;
private readonly ConcurrentDictionary<string, ApplicationState> _stateCache = new();

Expand All @@ -25,63 +27,35 @@ public async Task<ApplicationState> GetStateAsync(string applicationId)
{
var entityId = ToSafeHomeAssistantEntityIdFromApplicationId(applicationId);
if (_stateCache.TryGetValue(entityId, out var applicationState)) return applicationState;
// Since IHaContext is scoped and StateManager is singleton we get the
// IHaContext everytime we need to check state
var scope = _provider.CreateScope();
try
{
var haContext = scope.ServiceProvider.GetRequiredService<IHaContext>();

var appState = haContext.GetState(entityId);

if (appState is null)
{
haContext.SetEntityState(entityId, "on");
return ApplicationState.Enabled;
}

var appStateFromHomeAssistant =
appState.State == "on" ? ApplicationState.Enabled : ApplicationState.Disabled;
_stateCache[entityId] = appStateFromHomeAssistant;
return appStateFromHomeAssistant;
}
finally
{
if (scope is IAsyncDisposable serviceScopeAsyncDisposable)
await serviceScopeAsyncDisposable.DisposeAsync().ConfigureAwait(false);
}
return (await GetOrCreateStateForApp(entityId).ConfigureAwait(false))?.State == "on"
? ApplicationState.Enabled
: ApplicationState.Disabled;
}

public async Task SaveStateAsync(string applicationId, ApplicationState state)
{
// Since IHaContext is scoped and StateManager is singleton we get the
// IHaContext everytime we need to check state
var scope = _provider.CreateScope();
try
{
var haContext = scope.ServiceProvider.GetRequiredService<IHaContext>();
var entityId = ToSafeHomeAssistantEntityIdFromApplicationId(applicationId);
var haConnection = _provider.GetRequiredService<IHomeAssistantConnection>() ??
throw new InvalidOperationException();
var entityId = ToSafeHomeAssistantEntityIdFromApplicationId(applicationId);

switch (state)
{
case ApplicationState.Enabled:
haContext.SetEntityState(entityId, "on", new {app_state = "enabled"});
break;
case ApplicationState.Running:
haContext.SetEntityState(entityId, "on", new {app_state = "running"});
break;
case ApplicationState.Error:
haContext.SetEntityState(entityId, "on", new {app_state = "error"});
break;
case ApplicationState.Disabled:
haContext.SetEntityState(entityId, "off", new {app_state = "disabled"});
break;
}
}
finally
_stateCache[entityId] = state;

var currentState = (await GetOrCreateStateForApp(entityId).ConfigureAwait(false))?.State
?? throw new InvalidOperationException();

switch (state)
{
if (scope is IAsyncDisposable serviceScopeAsyncDisposable)
await serviceScopeAsyncDisposable.DisposeAsync().ConfigureAwait(false);
case ApplicationState.Enabled when currentState == "off":
await haConnection.CallServiceAsync("input_boolean", "turn_on",
new HassTarget {EntityIds = new[] {entityId}},
cancelToken: _cancelTokenSource.Token);
break;
case ApplicationState.Disabled when currentState == "on":
await haConnection.CallServiceAsync("input_boolean", "turn_off",
new HassTarget {EntityIds = new[] {entityId}},
cancelToken: _cancelTokenSource.Token);
break;
}
}

Expand Down Expand Up @@ -109,8 +83,6 @@ public void Initialize(IHomeAssistantConnection haConnection, IAppModelContext a
? ApplicationState.Enabled
: ApplicationState.Disabled;

_stateCache[entityId] = appState;

await app.SetStateAsync(
appState
);
Expand Down Expand Up @@ -148,6 +120,40 @@ public static string ToSafeHomeAssistantEntityIdFromApplicationId(string applica
break;
}

return $"switch.netdaemon_{stringBuilder.ToString().ToLowerInvariant()}";
return $"input_boolean.netdaemon_{stringBuilder.ToString().ToLowerInvariant()}";
}

private async Task<HassState?> GetOrCreateStateForApp(string entityId)
{
var haConnection = _provider.GetRequiredService<IHomeAssistantConnection>() ??
throw new InvalidOperationException();
try
{
var state = await haConnection.GetEntityStateAsync(entityId, _cancelTokenSource.Token)
.ConfigureAwait(false);
return state;
}
catch (HomeAssistantApiCallException e)
{
// Missing entity will throw a http status not found
if (e.Code == HttpStatusCode.NotFound)
{
// The app state input_boolean does not exist, lets create a helper
var name = entityId[14..]; // remove the "input_boolean." part
await haConnection.CreateInputBooleanHelperAsync(name, _cancelTokenSource.Token);
_stateCache[entityId] = ApplicationState.Enabled;
await haConnection.CallServiceAsync("input_boolean", "turn_on",
new HassTarget {EntityIds = new[] {entityId}},
cancelToken: _cancelTokenSource.Token).ConfigureAwait(false);
return new HassState {State = "on"};
}

throw;
}
}

public void Dispose()
{
_cancelTokenSource.Dispose();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using NetDaemon.Runtime.Internal.Model;

namespace NetDaemon.Runtime.Internal;

internal static class HomeAssistantConnectionExtensions
{
public static async Task<InputBooleanHelper?> CreateInputBooleanHelperAsync(
this IHomeAssistantConnection connection,
string name, CancellationToken cancelToken)
{
return await connection.SendCommandAndReturnResponseAsync<CreateInputBooleanHelperCommand, InputBooleanHelper?>(
new CreateInputBooleanHelperCommand
{
Name = name
}, cancelToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
using NetDaemon.Client.Common.HomeAssistant.Model;

namespace NetDaemon.Runtime.Internal.Model;

internal record CreateInputBooleanHelperCommand : CommandMessage
{
public CreateInputBooleanHelperCommand()
{
Type = "input_boolean/create";
}

[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
}
10 changes: 10 additions & 0 deletions src/Runtime/NetDaemon.Runtime/Internal/Model/HassHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;

namespace NetDaemon.Runtime.Internal.Model;

internal record InputBooleanHelper
{
[JsonPropertyName("name")] public string Name { get; init; } = string.Empty;
[JsonPropertyName("icon")] public string? Icon { get; init; }
[JsonPropertyName("id")] public string Id { get; init; } = string.Empty;
}