From 99002e49730fe1808aae42c8732367762fc22f8e Mon Sep 17 00:00:00 2001 From: David Date: Sat, 29 Jul 2023 20:24:11 -0400 Subject: [PATCH] chore: Misc updates --- src/Directory.Build.props | 1 + .../Mqtt/MqttClient.cs | 2 +- .../Mqtt/MqttSceneHost.cs | 10 +- .../SmartHome/Devices/Device.cs | 21 +++- .../SmartHome/Devices/DeviceState.cs | 2 +- .../SmartHome/Scenes/ISceneHost.cs | 6 +- .../SmartHome/Scenes/Scene.cs | 6 +- .../Hass/Api/HomeAssistantHttpApi.cs | 105 ++++++++++-------- .../Hass/Entities/InputTimeSpan.cs | 3 +- .../_ThirdParties/Hass/HomeAssistantConfig.cs | 27 +++++ .../_ThirdParties/Hass/HomeAssistantHub.cs | 23 ++-- .../Logging/SerilogAdapter.cs | 27 +++-- .../Utils/ConfigurationHelper.cs | 73 ++++++------ 13 files changed, 186 insertions(+), 120 deletions(-) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 8e6d09a..5472368 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -9,6 +9,7 @@ https://github.com/dr1rrb/smarthome.net iot home-assistant hass mqtt zigbee en-US + latest \ No newline at end of file diff --git a/src/SmartHomeDotNet.Package/Mqtt/MqttClient.cs b/src/SmartHomeDotNet.Package/Mqtt/MqttClient.cs index bb83a9e..7902903 100644 --- a/src/SmartHomeDotNet.Package/Mqtt/MqttClient.cs +++ b/src/SmartHomeDotNet.Package/Mqtt/MqttClient.cs @@ -442,7 +442,7 @@ public async Task Subscribe(CancellationToken ct, string topic) public Task Publish(CancellationToken ct, string topic, string value, QualityOfService qos, bool retain) { - this.Log().Info($"Publishing ({(retain?"retained": "volatile")}) message to topic '{topic}': {value}"); + this.Log().Debug($"Publishing ({(retain?"retained": "volatile")}) message to topic '{topic}': {value}"); var message = new MqttApplicationMessage(topic, Encoding.UTF8.GetBytes(value), retain); Task Send(IMqttClient client) => client.PublishAsync(message, (MqttQualityOfService)qos, retain); diff --git a/src/SmartHomeDotNet.Package/Mqtt/MqttSceneHost.cs b/src/SmartHomeDotNet.Package/Mqtt/MqttSceneHost.cs index 093f08e..7e27cdf 100644 --- a/src/SmartHomeDotNet.Package/Mqtt/MqttSceneHost.cs +++ b/src/SmartHomeDotNet.Package/Mqtt/MqttSceneHost.cs @@ -61,16 +61,20 @@ public async Task Initialized(CancellationToken ct, Scene scene) AvailabilityTopic = _mqtt.AvailabilityTopic, Icon = "mdi:script-text-outline" }; + var settings = new JsonSerializerSettings + { + NullValueHandling = NullValueHandling.Ignore, + Formatting = Formatting.None + }; // With first publish the scene using the "id" as "name" so Home assistant // will use it as "entity_id" (which cannot be configured from discovery component) // Then we publish it a second time using the right name. // Note: HA will not generate 2 different devices as we are providing device "unique_id" which stays the same. // Note: This is a patch which works only if HA is up when this config is published, if not, you can still change the entity_id from the UI - - await _mqtt.Publish(ct, $"homeassistant/switch/{id}/config", JsonConvert.SerializeObject(config), retain: !_mqtt.IsTestEnvironment); + await _mqtt.Publish(ct, $"homeassistant/switch/{id}/config", JsonConvert.SerializeObject(config, settings), retain: !_mqtt.IsTestEnvironment); config.Name = scene.Name; - await _mqtt.Publish(ct, $"homeassistant/switch/{id}/config", JsonConvert.SerializeObject(config), retain: !_mqtt.IsTestEnvironment); + await _mqtt.Publish(ct, $"homeassistant/switch/{id}/config", JsonConvert.SerializeObject(config, settings), retain: !_mqtt.IsTestEnvironment); } /// diff --git a/src/SmartHomeDotNet.Package/SmartHome/Devices/Device.cs b/src/SmartHomeDotNet.Package/SmartHome/Devices/Device.cs index 99095a6..c20a59b 100644 --- a/src/SmartHomeDotNet.Package/SmartHome/Devices/Device.cs +++ b/src/SmartHomeDotNet.Package/SmartHome/Devices/Device.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Dynamic; +using System.Globalization; using System.Linq; using System.Threading; @@ -49,13 +50,31 @@ void IDeviceAdapter.Init(DeviceState state, IDeviceHost host) protected virtual void OnInit() { } /// - /// Try to get the value of a property, if value is missing returns null + /// Try to get the value of a property, if value is missing returns null. /// /// Name of the property /// The value of the property or `null` is the property was not set. protected string GetValueOrDefault(string property) => GetState().Properties.GetValueOrDefault(property); + /// + /// Try to get the value of a property, if value is missing returns . + /// + /// Name of the property + /// The default value to return if the property is not set. + /// The value of the property or is the property was not set. + protected bool GetBoolOrDefault(string property, bool defaultValue = false) + => TryGetValue(property, out var rawValue) && bool.TryParse(rawValue, out var value) ? value : defaultValue; + + /// + /// Try to get the value of a property, if value is missing returns . + /// + /// Name of the property + /// The default value to return if the property is not set. + /// The value of the property or is the property was not set. + protected int GetInt32OrDefault(string property, int defaultValue = 0) + => TryGetValue(property, out var rawValue) && int.TryParse(rawValue, NumberStyles.Number, CultureInfo.InvariantCulture, out var value) ? value : defaultValue; + /// /// Try to get the value of a property /// diff --git a/src/SmartHomeDotNet.Package/SmartHome/Devices/DeviceState.cs b/src/SmartHomeDotNet.Package/SmartHome/Devices/DeviceState.cs index f41b0dd..9cd2f82 100644 --- a/src/SmartHomeDotNet.Package/SmartHome/Devices/DeviceState.cs +++ b/src/SmartHomeDotNet.Package/SmartHome/Devices/DeviceState.cs @@ -20,7 +20,7 @@ public class DeviceState public DeviceState(object deviceId, ImmutableDictionary properties, bool isPersistedState) { DeviceId = deviceId; - Properties = properties; + Properties = properties; // TODO .WithComparers(StringComparer.OrdinalIgnoreCase); => Actually we should even allow snake casing vs camel case IsPersistedState = isPersistedState; } diff --git a/src/SmartHomeDotNet.Package/SmartHome/Scenes/ISceneHost.cs b/src/SmartHomeDotNet.Package/SmartHome/Scenes/ISceneHost.cs index da4b077..5318008 100644 --- a/src/SmartHomeDotNet.Package/SmartHome/Scenes/ISceneHost.cs +++ b/src/SmartHomeDotNet.Package/SmartHome/Scenes/ISceneHost.cs @@ -22,13 +22,13 @@ public interface ISceneHost /// The target scene of the command /// An observable sequence of the sent to the scene IObservable ObserveCommands(Scene scene); - + /// /// Notifies the host that a has initialized and is now ready to handle commands /// /// Cancellation to cancel the asynchronous action /// The scene that has is now initialized - /// An synchonous operation + /// An asynchronous operation Task Initialized(CancellationToken ct, Scene scene); /// @@ -37,7 +37,7 @@ public interface ISceneHost /// /// The scene that has started or completed /// A boolean which indicates if the scene is now running or not - /// An synchonous operation + /// An asynchronous operation Task SetIsRunning(CancellationToken ct, Scene scene, bool isRunning); } } \ No newline at end of file diff --git a/src/SmartHomeDotNet.Package/SmartHome/Scenes/Scene.cs b/src/SmartHomeDotNet.Package/SmartHome/Scenes/Scene.cs index d57d0e4..f0da0e3 100644 --- a/src/SmartHomeDotNet.Package/SmartHome/Scenes/Scene.cs +++ b/src/SmartHomeDotNet.Package/SmartHome/Scenes/Scene.cs @@ -36,6 +36,8 @@ public enum RunOption /// AbortPending, + // Queue + // Parallel // AttachToPending // AttachToPendingWithCancellationToken } @@ -158,7 +160,7 @@ public async Task Run(CancellationToken ct, RunOption options = RunOption.Ignore /// Request to start the scene /// /// This is a "fire and forget". If you need to track the execution, you should use . - public void Start() + public void Start(RunOption options = RunOption.IgnoreIfRunning) { // This method is fire and forget, prevent flowing of the AsyncContext using (AsyncContext.None()) @@ -167,7 +169,7 @@ public void Start() { try { - await Run(ct); + await Run(ct, options); } catch (Exception e) { diff --git a/src/SmartHomeDotNet.Package/_ThirdParties/Hass/Api/HomeAssistantHttpApi.cs b/src/SmartHomeDotNet.Package/_ThirdParties/Hass/Api/HomeAssistantHttpApi.cs index a1ce2d0..35b3019 100644 --- a/src/SmartHomeDotNet.Package/_ThirdParties/Hass/Api/HomeAssistantHttpApi.cs +++ b/src/SmartHomeDotNet.Package/_ThirdParties/Hass/Api/HomeAssistantHttpApi.cs @@ -1,4 +1,6 @@ -using System; +#nullable enable + +using System; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; @@ -8,64 +10,71 @@ using Newtonsoft.Json; using SmartHomeDotNet.Utils; -namespace SmartHomeDotNet.Hass.Api +namespace SmartHomeDotNet.Hass.Api; + +/// +/// Represent the REST API of an . (cf. ). +/// +public class HomeAssistantHttpApi { + private readonly HttpClient _client; + private readonly string _host; + /// - /// Represent the REST API of an . (cf. ). + /// Creates a new instance given the uri and teh password of the REST API of a . /// - public class HomeAssistantHttpApi + /// The uri of the REST API, eg. IP_ADDRESS:8123 + /// The API token of your home assistant hub + public HomeAssistantHttpApi(string host, string apiToken) { - private readonly HttpClient _client; - private readonly string _host; + _host = host; - /// - /// Creates a new instance given the uri and teh password of the REST API of a . - /// - /// The uri of the REST API, eg. IP_ADDRESS:8123 - /// The API token of your home assistant hub - public HomeAssistantHttpApi(string host, string apiToken) + var handler = new HttpClientHandler(); + handler.ServerCertificateCustomValidationCallback = (message, certificate, chain, errors) => true; + _client = new HttpClient(handler) { - _host = host; - - var handler = new HttpClientHandler(); - handler.ServerCertificateCustomValidationCallback = (message, certificate, chain, errors) => true; - _client = new HttpClient(handler) + DefaultRequestHeaders = { - DefaultRequestHeaders = - { - Authorization = new AuthenticationHeaderValue("Bearer", apiToken) - } - }; - } + Authorization = new AuthenticationHeaderValue("Bearer", apiToken) + } + }; + } - /// - /// Call a service within a specific domain . - /// - /// The target domain of this call - /// The service to invoke - /// The parameters of the service (will be Json encoded), or `null` if no parameters - /// The expected duration of the effect of the commend (for instance the fade in/out of a light) - /// An asynchronous . - public AsyncContextOperation CallService(string domain, string service, object data, TimeSpan? transition = null) + /// + /// Call a service within a specific domain . + /// + /// The target domain of this call + /// The service to invoke + /// The parameters of the service (will be Json encoded), or `null` if no parameters + /// The expected duration of the effect of the commend (for instance the fade in/out of a light) + /// An asynchronous . + public AsyncContextOperation CallService(string domain, string service, object data, TimeSpan? transition = null) + { + if (transition.GetValueOrDefault() > TimeSpan.Zero) { - if (transition.GetValueOrDefault() > TimeSpan.Zero) - { - return AsyncContextOperation.StartNew(Send, ct => Task.Delay(transition.Value, ct)); - } - else - { - return AsyncContextOperation.StartNew(Send); - } + return AsyncContextOperation.StartNew(Send, ct => Task.Delay(transition.Value, ct)); + } + else + { + return AsyncContextOperation.StartNew(Send); + } - async Task Send(CancellationToken ct) - { - var content = data == null - ? null - : new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json"); - var response = await _client.PostAsync($"https://{_host}/api/services/{domain}/{service}", content, ct); + async Task Send(CancellationToken ct) + { + var content = data == null + ? null + : new StringContent(JsonConvert.SerializeObject(data), Encoding.UTF8, "application/json"); + var response = await _client.PostAsync($"https://{_host}/api/services/{domain}/{service}", content, ct); - response.EnsureSuccessStatusCode(); - } + response.EnsureSuccessStatusCode(); } } + + /// + /// Send an **authenticated** raw request to home assistant + /// + /// The requests to send + /// The asynchronous response from the server. + public Task Send(HttpRequestMessage request) + => _client.SendAsync(request); } \ No newline at end of file diff --git a/src/SmartHomeDotNet.Package/_ThirdParties/Hass/Entities/InputTimeSpan.cs b/src/SmartHomeDotNet.Package/_ThirdParties/Hass/Entities/InputTimeSpan.cs index 2f3f3d3..fb13e5d 100644 --- a/src/SmartHomeDotNet.Package/_ThirdParties/Hass/Entities/InputTimeSpan.cs +++ b/src/SmartHomeDotNet.Package/_ThirdParties/Hass/Entities/InputTimeSpan.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Linq; using SmartHomeDotNet.SmartHome.Devices; @@ -19,7 +20,7 @@ public class InputTimeSpan : Device, IInputTimeSpan /// /// Gets the defined VALUE /// - public TimeSpan Time => TimeSpan.FromSeconds(double.Parse(Raw.timestamp)); + public TimeSpan Time => TimeSpan.Parse(Raw.state, CultureInfo.InvariantCulture); public static implicit operator TimeSpan(InputTimeSpan input) => input.Time; diff --git a/src/SmartHomeDotNet.Package/_ThirdParties/Hass/HomeAssistantConfig.cs b/src/SmartHomeDotNet.Package/_ThirdParties/Hass/HomeAssistantConfig.cs index 4eb66a5..c2fb49b 100644 --- a/src/SmartHomeDotNet.Package/_ThirdParties/Hass/HomeAssistantConfig.cs +++ b/src/SmartHomeDotNet.Package/_ThirdParties/Hass/HomeAssistantConfig.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; @@ -41,5 +42,31 @@ public class HomeAssistantConfig [JsonProperty("retain")] public bool IsRetained { get; set; } + + [JsonProperty("device")] + public HomeAssistantConfigDevice Device { get; set; } + } + + public class HomeAssistantConfigDevice + { + public HomeAssistantConfigDevice(string identifier) + { + Identifiers = new List {identifier}; + } + + [JsonProperty("identifiers")] + public List Identifiers { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("sw_version")] + public string Firmware { get; set; } + + [JsonProperty("model")] + public string Model { get; set; } + + [JsonProperty("manufacturer")] + public string Manufacturer { get; set; } } } \ No newline at end of file diff --git a/src/SmartHomeDotNet.Package/_ThirdParties/Hass/HomeAssistantHub.cs b/src/SmartHomeDotNet.Package/_ThirdParties/Hass/HomeAssistantHub.cs index 610e817..8bd77ab 100644 --- a/src/SmartHomeDotNet.Package/_ThirdParties/Hass/HomeAssistantHub.cs +++ b/src/SmartHomeDotNet.Package/_ThirdParties/Hass/HomeAssistantHub.cs @@ -28,7 +28,6 @@ public class HomeAssistantHub : IDisposable public const string DefaultTopic = "homeassistant"; private readonly HomeAssistantDeviceHost _mqttStateStream; - private HomeAssistantWebSocketApi _ws; /// /// Creates an instance of a Home Assistant hub which will use MQTT state stream @@ -42,13 +41,15 @@ public class HomeAssistantHub : IDisposable /// A client to the MQTT broker used by Home Assistant /// Defines the base topic that will be used for to publish scenes and automations /// Defines the base topic used by home assistant state stream + /// Enabled scenes and automations discovery public HomeAssistantHub( IScheduler scheduler, string apiHostName, string apiToken, MqttClient mqtt, string homeTopic = DefaultHomeTopic, - string hassTopic = DefaultTopic) + string hassTopic = DefaultTopic, + bool enableDiscovery = true) { homeTopic = homeTopic.Trim('/', '#', '*'); hassTopic = hassTopic.Trim('/', '#', '*'); @@ -57,11 +58,10 @@ public HomeAssistantHub( _mqttStateStream = new HomeAssistantDeviceHost(mqtt, hassTopic, api, scheduler); Devices = new HomeDevicesManager(_mqttStateStream); - Scenes = new MqttSceneHost(mqtt, homeTopic, scheduler); - Automations = new MqttAutomationHost(mqtt, homeTopic, scheduler); + Scenes = new MqttSceneHost(mqtt, homeTopic, scheduler, enableDiscovery); + Automations = new MqttAutomationHost(mqtt, homeTopic, scheduler, enableDiscovery); Api = api; - - _ws = new HomeAssistantWebSocketApi(new Uri($"ws://{apiHostName}"), apiToken); + SocketApi = new HomeAssistantWebSocketApi(new Uri($"ws://{apiHostName}"), apiToken); } /// @@ -95,6 +95,11 @@ public HomeAssistantHub RegisterCommand(ICommandAdapter adapter) /// public HomeAssistantHttpApi Api { get; } + /// + /// Gets the websocket API of this instance of Home Assistant + /// + public HomeAssistantWebSocketApi SocketApi { get; } + /// /// Call a service on Home-Assistant /// @@ -106,7 +111,7 @@ internal AsyncContextOperation Send(HomeAssistantCommand command) { if (command is CallServiceCommand callService) { - if (_ws.IsConnected(out var connection)) + if (SocketApi.IsConnected(out var connection)) { return callService.Transition.HasValue ? AsyncContextOperation.StartNew(Send, Extent) @@ -116,7 +121,7 @@ async Task Send(CancellationToken ct) { using (connection) { - await _ws.Send(command, ct); + await SocketApi.Send(command, ct); } } @@ -132,7 +137,7 @@ async Task Extent(CancellationToken ct) } else { - return AsyncContextOperation.StartNew(async ct => await _ws.Send(command, ct)); + return AsyncContextOperation.StartNew(async ct => await SocketApi.Send(command, ct)); } } diff --git a/src/SmartHomeDotNetHost/Logging/SerilogAdapter.cs b/src/SmartHomeDotNetHost/Logging/SerilogAdapter.cs index 9c7e5bd..f1dc242 100644 --- a/src/SmartHomeDotNetHost/Logging/SerilogAdapter.cs +++ b/src/SmartHomeDotNetHost/Logging/SerilogAdapter.cs @@ -1,24 +1,23 @@ using System; using System.Linq; -namespace SmartHomeDotNet.Logging +namespace SmartHomeDotNet.Logging; + +internal class SerilogAdapter : ILogger { - internal class SerilogAdapter : ILogger - { - public static SerilogAdapter Instance { get; } = new SerilogAdapter(); + public static SerilogAdapter Instance { get; } = new SerilogAdapter(); - private SerilogAdapter() { } + private SerilogAdapter() { } - /// - public void Debug(string message) => Serilog.Log.Logger.Debug(message); + /// + public void Debug(string message) => Serilog.Log.Logger.Debug(message); - /// - public void Info(string message) => Serilog.Log.Logger.Information(message); + /// + public void Info(string message) => Serilog.Log.Logger.Information(message); - /// - public void Warning(string message) => Serilog.Log.Logger.Warning(message); + /// + public void Warning(string message) => Serilog.Log.Logger.Warning(message); - /// - public void Error(string message, Exception ex = null) => Serilog.Log.Logger.Error(ex, message); - } + /// + public void Error(string message, Exception? ex = null) => Serilog.Log.Logger.Error(ex, message); } \ No newline at end of file diff --git a/src/SmartHomeDotNetHost/Utils/ConfigurationHelper.cs b/src/SmartHomeDotNetHost/Utils/ConfigurationHelper.cs index af4a635..8b859b1 100644 --- a/src/SmartHomeDotNetHost/Utils/ConfigurationHelper.cs +++ b/src/SmartHomeDotNetHost/Utils/ConfigurationHelper.cs @@ -4,52 +4,51 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -namespace SmartHomeDotNet.Utils +namespace SmartHomeDotNet.Utils; + +public static class ConfigurationHelper { - public static class ConfigurationHelper + public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration config, Action buildAction) { - public static IServiceCollection AddConfiguration(this IServiceCollection services, IConfiguration config, Action buildAction) - { - var builder = new ConfigurationBuilder(services, config); - buildAction(builder); - builder.Validate(); + var builder = new ConfigurationBuilder(services, config); + buildAction(builder); + builder.Validate(); - return services; - } + return services; + } - public class ConfigurationBuilder - { - private readonly IServiceCollection _services; - private readonly IConfiguration _config; - private readonly IList<(string section, string error)> _errors = new List<(string, string)>(); + public class ConfigurationBuilder + { + private readonly IServiceCollection _services; + private readonly IConfiguration _config; + private readonly IList<(string section, string error)> _errors = new List<(string, string)>(); - public ConfigurationBuilder(IServiceCollection services, IConfiguration config) - { - _services = services; - _config = config; - } + public ConfigurationBuilder(IServiceCollection services, IConfiguration config) + { + _services = services; + _config = config; + } - public ConfigurationBuilder Add(string sectionName) - where T : class, IConfig, new() - { - var config = new T(); - _config.Bind(sectionName, config); - _errors.AddRange(config.Validate().Select(error => (sectionName, error))); - _services.AddSingleton(config); + public ConfigurationBuilder Add(string sectionName) + where T : class, IConfig, new() + { + var config = new T(); + _config.Bind(sectionName, config); + _errors.AddRange(config.Validate().Select(error => (sectionName, error))); + _services.AddSingleton(config); - return this; - } + return this; + } - public void Validate() + public void Validate() + { + if (_errors.Count > 0) { - if (_errors.Count > 0) - { - throw new InvalidOperationException( - "The configuration is invalid:" - + Environment.NewLine - + string.Join(Environment.NewLine, _errors.Select(e => $"\t{e.section}: {e.error}"))); - } + throw new InvalidOperationException( + "The configuration is invalid:" + + Environment.NewLine + + string.Join(Environment.NewLine, _errors.Select(e => $"\t{e.section}: {e.error}"))); } } } -} +} \ No newline at end of file