diff --git a/src/Client/NetDaemon.HassClient/Internal/Net/WebSocketTransportPipeline.cs b/src/Client/NetDaemon.HassClient/Internal/Net/WebSocketTransportPipeline.cs
index 6367f71a..0631ff21 100644
--- a/src/Client/NetDaemon.HassClient/Internal/Net/WebSocketTransportPipeline.cs
+++ b/src/Client/NetDaemon.HassClient/Internal/Net/WebSocketTransportPipeline.cs
@@ -139,7 +139,7 @@ private async Task ReadMessageFromWebSocketAndWriteToPipelineAsync(CancellationT
{
while (!cancelToken.IsCancellationRequested && !_ws.CloseStatus.HasValue)
{
- var memory = _pipe.Writer.GetMemory();
+ var memory = _pipe.Writer.GetMemory(8192);
var result = await _ws.ReceiveAsync(memory, cancelToken).ConfigureAwait(false);
if (
_ws.State == WebSocketState.Open &&
diff --git a/tests/Performance/PerfClient/PerfClient.csproj b/tests/Performance/PerfClient/PerfClient.csproj
new file mode 100644
index 00000000..33e54bcb
--- /dev/null
+++ b/tests/Performance/PerfClient/PerfClient.csproj
@@ -0,0 +1,40 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+
+
+
+
+ Always
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ..\..\..\.linting\roslynator.ruleset
+
+
+
+ Always
+
+
+
+
+
+
diff --git a/tests/Performance/PerfClient/Program.cs b/tests/Performance/PerfClient/Program.cs
new file mode 100644
index 00000000..268ffb2f
--- /dev/null
+++ b/tests/Performance/PerfClient/Program.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.Hosting;
+using NetDaemon.Runtime;
+using NetDaemon.AppModel;
+using System.Reflection;
+using Serilog;
+#pragma warning disable CA1812
+
+try
+{
+ await Host.CreateDefaultBuilder(args)
+ .UseNetDaemonAppSettings()
+ .UseSerilog((context, provider, logConfig) =>
+ {
+ logConfig.ReadFrom.Configuration(context.Configuration);
+ })
+ .UseNetDaemonRuntime()
+ .ConfigureServices((_, services) =>
+ services
+ .AddAppsFromAssembly(Assembly.GetEntryAssembly()!)
+ // Remove this is you are not running the integration!
+ //.AddNetDaemonStateManager()
+ )
+ .Build()
+ .RunAsync()
+ .ConfigureAwait(false);
+}
+catch (Exception e)
+{
+ Console.WriteLine($"Failed to start host... {e}");
+ throw;
+}
diff --git a/tests/Performance/PerfClient/apps/Config.yaml b/tests/Performance/PerfClient/apps/Config.yaml
new file mode 100644
index 00000000..8a942a0a
--- /dev/null
+++ b/tests/Performance/PerfClient/apps/Config.yaml
@@ -0,0 +1,2 @@
+# Future configuration file for the project.
+
diff --git a/tests/Performance/PerfClient/apps/PerformanceTestApp.cs b/tests/Performance/PerfClient/apps/PerformanceTestApp.cs
new file mode 100644
index 00000000..bf3c33fc
--- /dev/null
+++ b/tests/Performance/PerfClient/apps/PerformanceTestApp.cs
@@ -0,0 +1,38 @@
+using System.Diagnostics;
+using System.Reactive.Linq;
+using Microsoft.Extensions.Logging;
+using NetDaemon.AppModel;
+using NetDaemon.HassModel;
+
+#pragma warning disable CA1050
+
+///
+/// First starts the performance test and process all state changes and logs the performance
+///
+[NetDaemonApp]
+public class PerformanceTestApp
+{
+ public PerformanceTestApp(IHaContext ha, ILogger logger)
+ {
+ var counter = 0;
+ var timer = new Stopwatch();
+ ha.StateAllChanges()
+ .Subscribe(x =>
+ {
+ if (counter == 0)
+ {
+ timer.Start();
+ logger.LogInformation("Performance test started");
+ }
+ if (x.New?.State == "stop")
+ {
+ timer.Stop();
+ logger.LogInformation("Performance test completed in {Time}ms, with {Counter} call with performance {MsgPerSec} msg/sec", timer.ElapsedMilliseconds, counter, Math.Round( counter / (timer.ElapsedMilliseconds / 1000.0)));
+ timer.Reset();
+ }
+ counter++;
+ });
+ logger.LogInformation("Starting performance test by sending service call with service start_performance_test");
+ ha.CallService("netdaemon", "start_performance_test");
+ }
+}
diff --git a/tests/Performance/PerfClient/apps/PerformanceTestSecondApp.cs b/tests/Performance/PerfClient/apps/PerformanceTestSecondApp.cs
new file mode 100644
index 00000000..2b532e85
--- /dev/null
+++ b/tests/Performance/PerfClient/apps/PerformanceTestSecondApp.cs
@@ -0,0 +1,36 @@
+using System.Diagnostics;
+using System.Reactive.Linq;
+using Microsoft.Extensions.Logging;
+using NetDaemon.AppModel;
+using NetDaemon.HassModel;
+
+#pragma warning disable CA1050
+
+///
+/// Second app process all state changes and logs the performance
+///
+[NetDaemonApp]
+public class PerformanceTestSecondApp
+{
+ public PerformanceTestSecondApp(IHaContext ha, ILogger logger)
+ {
+ var counter = 0;
+ var timer = new Stopwatch();
+ ha.StateChanges()
+ .Subscribe(x =>
+ {
+ if (counter == 0)
+ {
+ timer.Start();
+ }
+ if (x.New?.State == "stop")
+ {
+ timer.Stop();
+ logger.LogInformation("Performance test completed in {Time}ms, with {Counter} call with performance {MsgPerSec} msg/sec", timer.ElapsedMilliseconds, counter, Math.Round( counter / (timer.ElapsedMilliseconds / 1000.0)));
+ timer.Reset();
+ }
+ counter++;
+ });
+ logger.LogInformation("Waiting for starting performance test");
+ }
+}
diff --git a/tests/Performance/PerfClient/apps/PerformanceTestThirdApp.cs b/tests/Performance/PerfClient/apps/PerformanceTestThirdApp.cs
new file mode 100644
index 00000000..b9d7d0c7
--- /dev/null
+++ b/tests/Performance/PerfClient/apps/PerformanceTestThirdApp.cs
@@ -0,0 +1,36 @@
+using System.Diagnostics;
+using System.Reactive.Linq;
+using Microsoft.Extensions.Logging;
+using NetDaemon.AppModel;
+using NetDaemon.HassModel;
+
+#pragma warning disable CA1050
+
+///
+/// Third app process all state changes and logs the performance
+///
+[NetDaemonApp]
+public class PerformanceTestThirdApp
+{
+ public PerformanceTestThirdApp(IHaContext ha, ILogger logger)
+ {
+ var counter = 0;
+ var timer = new Stopwatch();
+ ha.StateChanges()
+ .Subscribe(x =>
+ {
+ if (counter == 0)
+ {
+ timer.Start();
+ }
+ if (x.New?.State == "stop")
+ {
+ timer.Stop();
+ logger.LogInformation("Performance test completed in {Time}ms, with {Counter} call with performance {MsgPerSec} msg/sec", timer.ElapsedMilliseconds, counter, Math.Round( counter / (timer.ElapsedMilliseconds / 1000.0)));
+ timer.Reset();
+ }
+ counter++;
+ });
+ logger.LogInformation("Waiting for starting performance test");
+ }
+}
diff --git a/tests/Performance/PerfClient/appsettings.json b/tests/Performance/PerfClient/appsettings.json
new file mode 100644
index 00000000..4733ab40
--- /dev/null
+++ b/tests/Performance/PerfClient/appsettings.json
@@ -0,0 +1,33 @@
+{
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Verbose",
+ "Override": {
+ "System": "Information",
+ "Microsoft": "Information",
+ "System.Net.Http.HttpClient": "Warning",
+ "daemonapp.app": "Verbose"
+ }
+ },
+ "WriteTo": [
+ {
+ "Name": "Console",
+ "Args": {
+ "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console",
+ "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext:l}: {Message:lj}{NewLine}{Exception}"
+ }
+ }
+ ]
+ },
+ "HomeAssistant": {
+ "Host": "localhost",
+ "Port": 8002,
+ "Ssl": false,
+ "Token": "somefakeone",
+ "InsecureBypassCertificateErrors": false
+ },
+ "NetDaemon": {
+ "Admin": true,
+ "ApplicationConfigurationFolder": "./apps"
+ }
+}
diff --git a/tests/Performance/PerfServer/GlobalUsings.cs b/tests/Performance/PerfServer/GlobalUsings.cs
new file mode 100644
index 00000000..dbe55b95
--- /dev/null
+++ b/tests/Performance/PerfServer/GlobalUsings.cs
@@ -0,0 +1,21 @@
+global using System;
+global using System.Net.WebSockets;
+global using System.Reactive.Linq;
+global using System.Reactive.Threading.Tasks;
+global using System.Text;
+global using System.Text.Json;
+global using System.Text.Json.Serialization;
+global using System.Threading.Channels;
+global using Microsoft.Extensions.Logging;
+global using Microsoft.Extensions.Options;
+global using Microsoft.AspNetCore.Hosting.Server;
+global using Microsoft.AspNetCore.Hosting.Server.Features;
+global using NetDaemon.Client;
+global using NetDaemon.Client.HomeAssistant.Model;
+global using NetDaemon.Client.Internal;
+global using NetDaemon.Client.Internal.Helpers;
+global using NetDaemon.Client.Internal.Net;
+global using NetDaemon.Client.Internal.Extensions;
+global using NetDaemon.Client.Internal.HomeAssistant.Commands;
+global using NetDaemon.Client.Settings;
+global using NetDaemon.Client.HomeAssistant.Extensions;
diff --git a/tests/Performance/PerfServer/Internal/Commands.cs b/tests/Performance/PerfServer/Internal/Commands.cs
new file mode 100644
index 00000000..724c9eef
--- /dev/null
+++ b/tests/Performance/PerfServer/Internal/Commands.cs
@@ -0,0 +1,37 @@
+// Contains commands that being sent from ND that needs to be de-serialized by the fake sever
+
+namespace NetDaemon.Tests.Performance;
+
+#pragma warning disable CA1852
+
+public record CommandMessage
+{
+ [JsonPropertyName("type")] public string Type { get; init; } = string.Empty;
+ [JsonPropertyName("id")] public int Id { get; set; }
+}
+
+internal record CreateInputBooleanCommand : CommandMessage
+{
+ public CreateInputBooleanCommand()
+ {
+ Type = "input_boolean/create";
+ }
+
+ [JsonPropertyName("name")] public required string Name { get; init; }
+}
+
+internal record CallServiceCommand : CommandMessage
+{
+ public CallServiceCommand()
+ {
+ Type = "call_service";
+ }
+
+ [JsonPropertyName("domain")] public string Domain { get; init; } = string.Empty;
+
+ [JsonPropertyName("service")] public string Service { get; init; } = string.Empty;
+
+ [JsonPropertyName("service_data")] public object? ServiceData { get; init; }
+
+ [JsonPropertyName("target")] public HassTarget? Target { get; init; }
+}
diff --git a/tests/Performance/PerfServer/Internal/Messages.cs b/tests/Performance/PerfServer/Internal/Messages.cs
new file mode 100644
index 00000000..8ab07c85
--- /dev/null
+++ b/tests/Performance/PerfServer/Internal/Messages.cs
@@ -0,0 +1,914 @@
+// Contains all message templates that are sent from the fake server to the ND
+
+namespace NetDaemon.Tests.Performance;
+
+
+internal static class Messages
+{
+ public static string AuthRequiredMsg =>
+ $$"""
+ {
+ "type": "auth_required"
+ }
+ """;
+
+ public static string AuthOkMsg =>
+ $$"""
+ {
+ "type": "auth_ok",
+ "ha_version": "22.1.0"
+ }
+ """;
+
+ public static string ResultMsg(int id) =>
+ $$"""
+ {
+ "id": {{id}},
+ "type": "result",
+ "success": true,
+ "result": null
+ }
+ """;
+
+ public static string GetStatesResultMsg(int id) =>
+ $$"""
+ {
+ "id": {{id}},
+ "type": "result",
+ "success": true,
+ "result": [
+ {
+ "entity_id": "zone.test",
+ "state": "zoning",
+ "attributes": {
+ "hidden": true,
+ "latitude": 61.388466,
+ "longitude": 12.295939,
+ "radius": 200.0,
+ "friendly_name": "Test",
+ "icon": "mdi:briefcase"
+ },
+ "last_changed": "2019-02-16T18:11:44.183673+00:00",
+ "last_updated": "2019-02-16T18:11:44.183673+00:00",
+ "context": {
+ "id": "f48f9a312c68402a81230b9f14a00a23",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "zone.test2",
+ "state": "zoning",
+ "attributes": {
+ "hidden": true,
+ "latitude": 63.438067,
+ "longitude": 19.321611,
+ "radius": 300.0,
+ "friendly_name": "Test2",
+ "icon": "mdi:hospital"
+ },
+ "last_changed": "2019-02-16T18:11:44.184844+00:00",
+ "last_updated": "2019-02-16T18:11:44.184844+00:00",
+ "context": {
+ "id": "40a40e1aebe14464b2f7097c0a522810",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "zone.home",
+ "state": "zoning",
+ "attributes": {
+ "hidden": true,
+ "latitude": 61.4348599,
+ "longitude": 16.1413237,
+ "radius": 100,
+ "friendly_name": "Home",
+ "icon": "mdi:home"
+ },
+ "last_changed": "2019-02-16T18:11:44.187462+00:00",
+ "last_updated": "2019-02-16T18:11:44.187462+00:00",
+ "context": {
+ "id": "cd7715bcd37b4b528540dc4bfa634de9",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "sun.sun",
+ "state": "below_horizon",
+ "attributes": {
+ "next_dawn": "2019-02-17T05:47:33+00:00",
+ "next_dusk": "2019-02-17T16:24:13+00:00",
+ "next_midnight": "2019-02-16T23:05:52+00:00",
+ "next_noon": "2019-02-17T11:05:53+00:00",
+ "next_rising": "2019-02-17T06:35:00+00:00",
+ "next_setting": "2019-02-17T15:36:46+00:00",
+ "elevation": -39.06,
+ "azimuth": 348.2,
+ "friendly_name": "Sun"
+ },
+ "last_changed": "2019-02-16T18:11:44.204384+00:00",
+ "last_updated": "2019-02-16T22:28:30.002772+00:00",
+ "context": {
+ "id": "2f6c1d0cee6a454d853150c7df4104c5",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "group.default_view",
+ "state": "on",
+ "attributes": {
+ "entity_id": [
+ "group.climate",
+ "group.uteclimate",
+ "group.upper_floor",
+ "group.lower_floor",
+ "group.outside",
+ "sensor.my_alarm",
+ "input_select.house_mode_select",
+ "input_boolean.good_night_house",
+ "sensor.house_mode",
+ "group.frysar_temperatur",
+ "group.people_status",
+ "sensor.occupancy",
+ "weather.smhi_hemma",
+ "weather.yweather"
+ ],
+ "order": 0,
+ "view": true,
+ "friendly_name": "default_view",
+ "icon": "mdi:home",
+ "hidden": true,
+ "assumed_state": true
+ },
+ "last_changed": "2019-02-16T18:11:45.104467+00:00",
+ "last_updated": "2019-02-16T18:11:45.691968+00:00",
+ "context": {
+ "id": "d3b3e20ea93f49b190f56e08d5af5e80",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "scene.stadning",
+ "state": "scening",
+ "attributes": {
+ "entity_id": [
+ "group.dummy"
+ ],
+ "friendly_name": "St\u00e4dning"
+ },
+ "last_changed": "2019-02-16T18:11:44.703590+00:00",
+ "last_updated": "2019-02-16T18:11:44.703590+00:00",
+ "context": {
+ "id": "441f99ad4a4d44358b3aaa73e248a187",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "sensor.house_mode",
+ "state": "Kv\u00e4ll",
+ "attributes": {
+ "friendly_name": "Hus status"
+ },
+ "last_changed": "2019-02-16T18:11:49.853247+00:00",
+ "last_updated": "2019-02-16T18:11:49.853247+00:00",
+ "context": {
+ "id": "ff7c757b76f746da800158f70f1639a1",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "sensor.sally_phone_mqtt_bt",
+ "state": "home",
+ "attributes": {
+ "confidence": "100",
+ "friendly_name": "Sallys mobil BT",
+ "entity_picture": "/local/sally.jpg"
+ },
+ "last_changed": "2019-02-16T18:32:22.167753+00:00",
+ "last_updated": "2019-02-16T18:32:22.167753+00:00",
+ "context": {
+ "id": "0c9978ae1ce24a208c9a25272c44950b",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "state": "on",
+ "attributes": {
+ "battery_level": 100,
+ "on": true,
+ "friendly_name": "R\u00f6relsedetektor vardagsrum uppe",
+ "device_class": "motion",
+ "icon": "mdi:run-fast"
+ },
+ "last_changed": "2019-02-16T21:57:39.435927+00:00",
+ "last_updated": "2019-02-16T21:57:39.435927+00:00",
+ "context": {
+ "id": "5f88441613664f2f94bfcb7202dabb3b",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "group.room_view",
+ "state": "on",
+ "attributes": {
+ "entity_id": [
+ "group.room_sally",
+ "group.room_bedroom"
+ ],
+ "order": 1,
+ "view": true,
+ "friendly_name": "room_view",
+ "icon": "mdi:home-circle",
+ "hidden": true,
+ "assumed_state": true
+ },
+ "last_changed": "2019-02-16T18:11:45.102432+00:00",
+ "last_updated": "2019-02-16T18:11:45.689337+00:00",
+ "context": {
+ "id": "3f5092353d354d16abc4383e2b859278",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "group.ligths_view",
+ "state": "on",
+ "attributes": {
+ "entity_id": [
+ "light.tomas_fonster",
+ "light.tvrummet"
+ ],
+ "order": 5,
+ "view": true,
+ "friendly_name": "ligths_view",
+ "icon": "mdi:lightbulb-outline",
+ "hidden": true,
+ "assumed_state": true
+ },
+ "last_changed": "2019-02-16T18:11:46.025370+00:00",
+ "last_updated": "2019-02-16T18:11:46.025370+00:00",
+ "context": {
+ "id": "b1806cf9774149c38d72d27c221539c1",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "switch.switch1",
+ "state": "off",
+ "attributes": {
+ "friendly_name": "switch1",
+ "assumed_state": true
+ },
+ "last_changed": "2019-02-16T18:11:45.613212+00:00",
+ "last_updated": "2019-02-16T18:11:45.613212+00:00",
+ "context": {
+ "id": "9b38bb49ad474ed1a839eb36030e89c6",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "group.all_switches",
+ "state": "on",
+ "attributes": {
+ "entity_id": [
+ "switch.switch1",
+ "switch.tv"
+ ],
+ "order": 35,
+ "auto": true,
+ "friendly_name": "all switches",
+ "hidden": true,
+ "assumed_state": true
+ },
+ "last_changed": "2019-02-16T18:11:46.665404+00:00",
+ "last_updated": "2019-02-16T18:11:46.665404+00:00",
+ "context": {
+ "id": "94d4ee3f3503466388c43b435237b0d4",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "group.all_lights",
+ "state": "on",
+ "attributes": {
+ "entity_id": [
+ "light.sallys_rum",
+ "light.tvrummet"
+ ],
+ "order": 36,
+ "auto": true,
+ "friendly_name": "all lights",
+ "hidden": true
+ },
+ "last_changed": "2019-02-16T18:11:45.806440+00:00",
+ "last_updated": "2019-02-16T18:12:02.869362+00:00",
+ "context": {
+ "id": "3cab55e957bd45d083229c9c441e806b",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "light.tvrummet_vanster",
+ "state": "on",
+ "attributes": {
+ "min_mireds": 153,
+ "max_mireds": 500,
+ "brightness": 63,
+ "color_temp": 442,
+ "is_deconz_group": false,
+ "friendly_name": "tvrummet_vanster",
+ "supported_features": 43
+ },
+ "last_changed": "2019-02-16T18:11:45.977388+00:00",
+ "last_updated": "2019-02-16T18:11:45.977388+00:00",
+ "context": {
+ "id": "ae2a620ae48d48fb91907d6f6e379419",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "light.sovrum_fonster",
+ "state": "on",
+ "attributes": {
+ "brightness": 63,
+ "is_deconz_group": false,
+ "friendly_name": "sovrum_fonster",
+ "supported_features": 41
+ },
+ "last_changed": "2019-02-16T18:11:45.978600+00:00",
+ "last_updated": "2019-02-16T18:11:45.978600+00:00",
+ "context": {
+ "id": "c7f79ba696334de7a18c895c0491d499",
+ "parent_id": null,
+ "user_id": null
+ }
+ },
+ {
+ "entity_id": "sensor.occupancy",
+ "state": "home",
+ "attributes": {
+ "last_seen": "R\u00f6relsedetektor Tomas rum 3 minutes ago",
+ "time_last_seen": "2019-02-16 23:24"
+ },
+ "last_changed": "2019-02-16T18:11:53.596418+00:00",
+ "last_updated": "2019-02-16T22:28:00.107816+00:00",
+ "context": {
+ "id": "f26aa4b08eb34fa58680bc2a0ca3b7bd",
+ "parent_id": null,
+ "user_id": "5a6c8d01ee2f4e4b90c7df8ea1ddd526"
+ }
+ },
+ {
+ "entity_id": "sensor.presence_tomas",
+ "state": "Home",
+ "attributes": {
+ "entity_picture": "/local/tomas.jpg",
+ "friendly_name": "Tomas tracker",
+ "latitude": 61.233804,
+ "longitude": 16.0312271,
+ "proxi_direction": "stationary",
+ "proxi_distance": 0,
+ "source_type": "gps",
+ "gps_accuracy": 16
+ },
+ "last_changed": "2019-02-16T18:11:53.650806+00:00",
+ "last_updated": "2019-02-16T19:01:07.960903+00:00",
+ "context": {
+ "id": "8e768b8dc56a4257bb0540f9734a06de",
+ "parent_id": null,
+ "user_id": "5a6c8d01ee2f4e4b90c7df8ea1ddd526"
+ }
+ },
+ {
+ "entity_id": "persistent_notification.http_login",
+ "state": "notifying",
+ "attributes": {
+ "title": "Login attempt failed",
+ "message": "Login attempt or request with invalid authentication from 111.22.3.4"
+ },
+ "last_changed": "2019-02-16T21:45:59.552536+00:00",
+ "last_updated": "2019-02-16T21:45:59.552536+00:00",
+ "context": {
+ "id": "e7f1576b60f1406397b9a8cfc75f1329",
+ "parent_id": null,
+ "user_id": null
+ }
+ }
+ ]
+ }
+ """;
+
+ public static string GetConfigResultMsg(int id) =>
+ $$"""
+ {
+ "id": {{id}},
+ "type": "result",
+ "success": true,
+ "result": {
+ "latitude": 63.1394549,
+ "longitude": 12.3412267,
+ "elevation": 29,
+ "state": "RUNNING",
+ "unit_system": {
+ "length": "km",
+ "mass": "g",
+ "temperature": "\u00b0C",
+ "volume": "L"
+ },
+ "location_name": "Home",
+ "time_zone": "Europe/Stockholm",
+ "components": [
+ "config.auth",
+ "binary_sensor.deconz",
+ "websocket_api",
+ "deconz",
+ "input_boolean",
+ "api",
+ "config.automation",
+ "zone",
+ "automation",
+ "media_player",
+ "scene.homeassistant",
+ "system_health",
+ "auth",
+ "config.config_entries",
+ "logbook",
+ "config.script",
+ "influxdb",
+ "logger",
+ "input_number",
+ "sensor.template",
+ "config.core",
+ "light",
+ "tts",
+ "system_log",
+ "sensor",
+ "switch",
+ "binary_sensor",
+ "recorder",
+ "input_select",
+ "weather",
+ "onboarding",
+ "media_player.cast",
+ "hassio",
+ "history",
+ "script",
+ "group",
+ "config.entity_registry",
+ "map",
+ "config.device_registry",
+ "mqtt",
+ "config",
+ "discovery",
+ "cast",
+ "http",
+ "device_tracker",
+ "notify",
+ "config.customize",
+ "config.auth_provider_homeassistant",
+ "switch.template",
+ "scene",
+ "config.group",
+ "camera",
+ "updater",
+ "sensor.systemmonitor",
+ "proximity",
+ "cover",
+ "sensor.time_date",
+ "frontend",
+ "sun",
+ "config.area_registry",
+ "lovelace"
+ ],
+ "config_dir": "/config",
+ "whitelist_external_dirs": [
+ "/config/www"
+ ],
+ "version": "0.87.0"
+ }
+ }
+ """;
+
+ public static string GetAreasResultMsg(int id) =>
+ $$"""
+ {
+ "id": {{id}},
+ "type": "result",
+ "success": true,
+ "result": [
+ {
+ "name": "Bedroom",
+ "area_id": "5a30cdc2fd7f44d5a77f2d6f6d2ccd76"
+ },
+ {
+ "name": "Kitchen",
+ "area_id": "42a6048dc0404595b136545f6745c5d1"
+ },
+ {
+ "name": "Livingroom",
+ "area_id": "4e65b6fe3cea4604ab318b0f9c2b8432"
+ }
+ ]
+ }
+ """;
+
+ public static string GetLabelsResultMsg(int id) =>
+ $$"""
+ {
+ "id": {{id}},
+ "type": "result",
+ "success": true,
+ "result": [
+ {
+ "color": "green",
+ "description": null,
+ "icon": "mdi:chair-rolling",
+ "label_id": "label1",
+ "name": "Label 1"
+ },
+ {
+ "color": "indigo",
+ "description": null,
+ "icon": "mdi:lightbulb-night",
+ "label_id": "nightlights",
+ "name": "NightLights"
+ }
+ ]
+ }
+ """;
+
+ public static string GetFloorsResultMsg(int id) =>
+ $$"""
+ {
+ "id": {{id}},
+ "type": "result",
+ "success": true,
+ "result": [
+ {
+ "aliases": [],
+ "floor_id": "floor0",
+ "icon": null,
+ "level": 0,
+ "name": "Floor 0"
+ },
+ {
+ "aliases": [],
+ "floor_id": "floor1",
+ "icon": null,
+ "level": 1,
+ "name": "Floor 1"
+ }
+ ]
+ }
+ """;
+
+ public static string GetDevicesResultMsg(int id) =>
+ $$"""
+ {
+ "id": {{id}},
+ "type": "result",
+ "success": true,
+ "result": [
+ {
+ "config_entries": [],
+ "connections": [],
+ "manufacturer": "Google Inc.",
+ "model": "Chromecast",
+ "name": "My TV",
+ "sw_version": null,
+ "id": "42cdda32a2a3428e86c2e27699d79ead",
+ "via_device_id": null,
+ "area_id": null,
+ "name_by_user": null
+ },
+ {
+ "config_entries": [
+ "4b85129c61c74b27bd90e593f6b7482e"
+ ],
+ "connections": [],
+ "manufacturer": "Plex",
+ "model": "Plex Web",
+ "name": "Plex (Plex Web - Chrome)",
+ "sw_version": "4.22.3",
+ "id": "49b27477238a4c8fb6cc8fbac32cebbc",
+ "via_device_id": "6e17380a6d2744d18045fe4f627db706",
+ "area_id": null,
+ "name_by_user": null
+ }
+ ]
+ }
+ """;
+
+ public static string GetEntitiesResultMsg(int id) =>
+ $$"""
+ {
+ "id": {{id}},
+ "type": "result",
+ "success": true,
+ "result": [
+ {
+ "config_entry_id": null,
+ "device_id": "42cdda32a2a3428e86c2e27699d79ead",
+ "disabled_by": null,
+ "entity_id": "media_player.tv_uppe2",
+ "area_id": "42cdda1212a3428e86c2e27699d79ead",
+ "name": null,
+ "icon": null,
+ "platform": "cast"
+ },
+ {
+ "config_entry_id": "4b85129c61c74b27bd90e593f6b7482e",
+ "device_id": "6e17380a6d2744d18045fe4f627db706",
+ "disabled_by": null,
+ "entity_id": "sensor.plex_plex",
+ "name": null,
+ "icon": null,
+ "platform": "plex"
+ }
+ ]
+ }
+ """;
+
+ internal static string InputBooleanCreateResultMsg(int id) =>
+ $$"""
+ {
+ "id": {{id}},
+ "type": "result",
+ "success": true,
+ "result": null
+ }
+ """;
+
+ public static string EventResultMsgMultiple(int id, string stateFrom, string stateTo) =>
+ $$"""
+ [
+ {
+ "id": {{id}},
+ "type": "event",
+ "event": {
+ "event_type": "state_changed",
+ "data": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "old_state": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "state": "{{stateFrom}}",
+ "attributes": {
+ "battery_level": 100,
+ "on": true,
+ "friendly_name": "R\u00f6relsedetektor TV-rum",
+ "device_class": "motion",
+ "icon": "mdi:run-fast"
+ },
+ "last_changed": "2019-02-17T11:41:08.015070+00:00",
+ "last_updated": "2019-02-17T11:42:08.015070+00:00",
+ "context": {
+ "id": "09c2e2ed8eef43e7885f478084e61d80",
+ "user_id": null
+ }
+ },
+ "new_state": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "state": "{{stateTo}}",
+ "attributes": {
+ "battery_level": 100,
+ "on": true,
+ "friendly_name": "R\u00f6relsedetektor vardagsrum",
+ "device_class": "motion",
+ "icon": "mdi:run-fast"
+ },
+ "last_changed": "2019-02-17T11:43:47.090473+00:00",
+ "last_updated": "2019-02-17T11:43:47.090473+00:00",
+ "context": {
+ "id": "849ebede7b294a019c724a07dac43f9c",
+ "user_id": null
+ }
+ }
+ },
+ "origin": "LOCAL",
+ "time_fired": "2019-02-17T11:43:47.090511+00:00",
+ "context": {
+ "id": "849ebede7b294a019c724a07dac43f9c",
+ "user_id": null
+ }
+ }
+ },
+ {
+ "id": {{id}},
+ "type": "event",
+ "event": {
+ "event_type": "state_changed",
+ "data": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "old_state": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "state": "{{stateFrom}}",
+ "attributes": {
+ "battery_level": 100,
+ "on": true,
+ "friendly_name": "R\u00f6relsedetektor TV-rum",
+ "device_class": "motion",
+ "icon": "mdi:run-fast"
+ },
+ "last_changed": "2019-02-17T11:41:08.015070+00:00",
+ "last_updated": "2019-02-17T11:42:08.015070+00:00",
+ "context": {
+ "id": "09c2e2ed8eef43e7885f478084e61d80",
+ "user_id": null
+ }
+ },
+ "new_state": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "state": "{{stateTo}}",
+ "attributes": {
+ "battery_level": 100,
+ "on": true,
+ "friendly_name": "R\u00f6relsedetektor vardagsrum",
+ "device_class": "motion",
+ "icon": "mdi:run-fast"
+ },
+ "last_changed": "2019-02-17T11:43:47.090473+00:00",
+ "last_updated": "2019-02-17T11:43:47.090473+00:00",
+ "context": {
+ "id": "849ebede7b294a019c724a07dac43f9c",
+ "user_id": null
+ }
+ }
+ },
+ "origin": "LOCAL",
+ "time_fired": "2019-02-17T11:43:47.090511+00:00",
+ "context": {
+ "id": "849ebede7b294a019c724a07dac43f9c",
+ "user_id": null
+ }
+ }
+ },
+ {
+ "id": {{id}},
+ "type": "event",
+ "event": {
+ "event_type": "state_changed",
+ "data": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "old_state": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "state": "{{stateFrom}}",
+ "attributes": {
+ "battery_level": 100,
+ "on": true,
+ "friendly_name": "R\u00f6relsedetektor TV-rum",
+ "device_class": "motion",
+ "icon": "mdi:run-fast"
+ },
+ "last_changed": "2019-02-17T11:41:08.015070+00:00",
+ "last_updated": "2019-02-17T11:42:08.015070+00:00",
+ "context": {
+ "id": "09c2e2ed8eef43e7885f478084e61d80",
+ "user_id": null
+ }
+ },
+ "new_state": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "state": "{{stateTo}}",
+ "attributes": {
+ "battery_level": 100,
+ "on": true,
+ "friendly_name": "R\u00f6relsedetektor vardagsrum",
+ "device_class": "motion",
+ "icon": "mdi:run-fast"
+ },
+ "last_changed": "2019-02-17T11:43:47.090473+00:00",
+ "last_updated": "2019-02-17T11:43:47.090473+00:00",
+ "context": {
+ "id": "849ebede7b294a019c724a07dac43f9c",
+ "user_id": null
+ }
+ }
+ },
+ "origin": "LOCAL",
+ "time_fired": "2019-02-17T11:43:47.090511+00:00",
+ "context": {
+ "id": "849ebede7b294a019c724a07dac43f9c",
+ "user_id": null
+ }
+ }
+ },
+ {
+ "id": {{id}},
+ "type": "event",
+ "event": {
+ "event_type": "state_changed",
+ "data": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "old_state": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "state": "{{stateFrom}}",
+ "attributes": {
+ "battery_level": 100,
+ "on": true,
+ "friendly_name": "R\u00f6relsedetektor TV-rum",
+ "device_class": "motion",
+ "icon": "mdi:run-fast"
+ },
+ "last_changed": "2019-02-17T11:41:08.015070+00:00",
+ "last_updated": "2019-02-17T11:42:08.015070+00:00",
+ "context": {
+ "id": "09c2e2ed8eef43e7885f478084e61d80",
+ "user_id": null
+ }
+ },
+ "new_state": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "state": "{{stateTo}}",
+ "attributes": {
+ "battery_level": 100,
+ "on": true,
+ "friendly_name": "R\u00f6relsedetektor vardagsrum",
+ "device_class": "motion",
+ "icon": "mdi:run-fast"
+ },
+ "last_changed": "2019-02-17T11:43:47.090473+00:00",
+ "last_updated": "2019-02-17T11:43:47.090473+00:00",
+ "context": {
+ "id": "849ebede7b294a019c724a07dac43f9c",
+ "user_id": null
+ }
+ }
+ },
+ "origin": "LOCAL",
+ "time_fired": "2019-02-17T11:43:47.090511+00:00",
+ "context": {
+ "id": "849ebede7b294a019c724a07dac43f9c",
+ "user_id": null
+ }
+ }
+ }
+ ]
+ """;
+ public static string EventResultMsg(int id, string stateFrom, string stateTo) =>
+ $$"""
+ {
+ "id": {{id}},
+ "type": "event",
+ "event": {
+ "event_type": "state_changed",
+ "data": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "old_state": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "state": "{{stateFrom}}",
+ "attributes": {
+ "battery_level": 100,
+ "on": true,
+ "friendly_name": "R\u00f6relsedetektor TV-rum",
+ "device_class": "motion",
+ "icon": "mdi:run-fast"
+ },
+ "last_changed": "2019-02-17T11:41:08.015070+00:00",
+ "last_updated": "2019-02-17T11:42:08.015070+00:00",
+ "context": {
+ "id": "09c2e2ed8eef43e7885f478084e61d80",
+ "user_id": null
+ }
+ },
+ "new_state": {
+ "entity_id": "binary_sensor.vardagsrum_pir",
+ "state": "{{stateTo}}",
+ "attributes": {
+ "battery_level": 100,
+ "on": true,
+ "friendly_name": "R\u00f6relsedetektor vardagsrum",
+ "device_class": "motion",
+ "icon": "mdi:run-fast"
+ },
+ "last_changed": "2019-02-17T11:43:47.090473+00:00",
+ "last_updated": "2019-02-17T11:43:47.090473+00:00",
+ "context": {
+ "id": "849ebede7b294a019c724a07dac43f9c",
+ "user_id": null
+ }
+ }
+ },
+ "origin": "LOCAL",
+ "time_fired": "2019-02-17T11:43:47.090511+00:00",
+ "context": {
+ "id": "849ebede7b294a019c724a07dac43f9c",
+ "user_id": null
+ }
+ }
+ }
+ """;
+}
diff --git a/tests/Performance/PerfServer/Internal/Model.cs b/tests/Performance/PerfServer/Internal/Model.cs
new file mode 100644
index 00000000..80c91ab8
--- /dev/null
+++ b/tests/Performance/PerfServer/Internal/Model.cs
@@ -0,0 +1,21 @@
+namespace NetDaemon.Tests.Performance;
+
+public record InputBoolean
+{
+ [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;
+}
+
+public record EntityState
+{
+
+ [JsonPropertyName("attributes")] public IDictionary? Attributes { get; init; } = null;
+
+ [JsonPropertyName("entity_id")] public string EntityId { get; init; } = "";
+
+ [JsonPropertyName("last_changed")] public DateTime LastChanged { get; init; } = DateTime.MinValue;
+ [JsonPropertyName("last_updated")] public DateTime LastUpdated { get; init; } = DateTime.MinValue;
+ [JsonPropertyName("state")] public string? State { get; init; } = "";
+ [JsonPropertyName("context")] public HassContext? Context { get; init; }
+}
diff --git a/tests/Performance/PerfServer/Internal/PerfServer.cs b/tests/Performance/PerfServer/Internal/PerfServer.cs
new file mode 100644
index 00000000..d307b952
--- /dev/null
+++ b/tests/Performance/PerfServer/Internal/PerfServer.cs
@@ -0,0 +1,276 @@
+using System.Collections.Concurrent;
+
+namespace NetDaemon.Tests.Performance;
+
+///
+/// The class implementing the mock hass server
+///
+public sealed class PerfServerStartup(ILogger logger) : IAsyncDisposable
+{
+ private readonly CancellationTokenSource _cancelSource = new();
+
+ private const int DefaultTimeOut = 5000;
+ private const int RecieiveBufferSize = 1024 * 4;
+
+ private readonly ConcurrentBag _eventSubscriptions = [];
+ private Task? _perfTestTask;
+
+ public static readonly ConcurrentBag _inputBooleans = [];
+
+ ///
+ /// Sends a websocket message to the client
+ ///
+ private async Task SendWebsocketMessage(WebSocket ws, string message)
+ {
+ var byteMessage = Encoding.UTF8.GetBytes(message);
+ await ws.SendAsync(new ArraySegment(byteMessage, 0, byteMessage.Length),
+ WebSocketMessageType.Text, true, _cancelSource.Token).ConfigureAwait(false);
+ }
+
+ ///
+ /// Process incoming websocket requests to simulate Home Assistant websocket API
+ ///
+ ///
+ /// This implements just enough of the HA websocket API to make NetDaemon happy
+ ///
+ public async Task ProcessWebsocket(WebSocket webSocket)
+ {
+ logger.LogDebug("Processing websocket");
+
+ var buffer = new byte[RecieiveBufferSize];
+
+ try
+ {
+ // First send auth required to the client
+ var authRequiredMessage = Messages.AuthRequiredMsg;
+ await SendWebsocketMessage(webSocket, authRequiredMessage).ConfigureAwait(false);
+
+ while (true)
+ {
+ // Wait for incoming messages
+ var result =
+ await webSocket.ReceiveAsync(new ArraySegment(buffer), _cancelSource.Token)
+ .ConfigureAwait(false);
+
+ logger.LogDebug("Received message: {Message}", Encoding.UTF8.GetString(buffer, 0, result.Count));
+
+ _cancelSource.Token.ThrowIfCancellationRequested();
+
+ if (result.CloseStatus.HasValue && webSocket.State == WebSocketState.CloseReceived)
+ {
+ logger.LogDebug("Close message received, closing websocket");
+ break;
+ }
+
+ var hassMessage =
+ JsonSerializer.Deserialize(new ReadOnlySpan(buffer, 0, result.Count))
+ ?? throw new ApplicationException("Unexpected not able to deserialize to HassMessage");
+
+ switch (hassMessage.Type)
+ {
+ case "auth":
+ // Just auth ok anything
+ var authOkMessage = Messages.AuthOkMsg;
+ await SendWebsocketMessage(webSocket, authOkMessage).ConfigureAwait(false);
+ break;
+ case "subscribe_events":
+ _eventSubscriptions.Add(hassMessage.Id);
+ var resultMsg = Messages.ResultMsg(hassMessage.Id);
+ await SendWebsocketMessage(webSocket, resultMsg).ConfigureAwait(false);
+ break;
+ case "get_states":
+ var getStatesResultMsg = Messages.GetStatesResultMsg(hassMessage.Id);
+ await SendWebsocketMessage(webSocket, getStatesResultMsg).ConfigureAwait(false);
+ break;
+ case "call_service":
+ var callServiceMsg = Messages.ResultMsg(hassMessage.Id);
+ var callServiceCommand =
+ JsonSerializer.Deserialize(
+ new ReadOnlySpan(buffer, 0, result.Count))
+ ?? throw new ApplicationException("Unexpected not able to deserialize call service");
+
+ if (callServiceCommand.Service == "start_performance_test")
+ {
+ _perfTestTask = StartPerformanceTest(webSocket);
+ }
+ await SendWebsocketMessage(webSocket, callServiceMsg).ConfigureAwait(false);
+ break;
+ case "get_config":
+ var getConfigResultMsg = Messages.GetConfigResultMsg(hassMessage.Id);
+ await SendWebsocketMessage(webSocket, getConfigResultMsg).ConfigureAwait(false);
+ break;
+ case "config/area_registry/list":
+ var getAreasResultMsg = Messages.GetAreasResultMsg(hassMessage.Id);
+ await SendWebsocketMessage(webSocket, getAreasResultMsg).ConfigureAwait(false);
+ break;
+ case "config/label_registry/list":
+ var getLabelsResultMsg = Messages.GetLabelsResultMsg(hassMessage.Id);
+ await SendWebsocketMessage(webSocket, getLabelsResultMsg).ConfigureAwait(false);
+ break;
+ case "config/floor_registry/list":
+ var getFloorsResultMsg = Messages.GetFloorsResultMsg(hassMessage.Id);
+ await SendWebsocketMessage(webSocket, getFloorsResultMsg).ConfigureAwait(false);
+ break;
+ case "config/device_registry/list":
+ var devicesResultMsg = Messages.GetDevicesResultMsg(hassMessage.Id);
+ await SendWebsocketMessage(webSocket, devicesResultMsg).ConfigureAwait(false);
+ break;
+ case "config/entity_registry/list":
+ var entitiesResultMsg = Messages.GetEntitiesResultMsg(hassMessage.Id);
+ await SendWebsocketMessage(webSocket, entitiesResultMsg).ConfigureAwait(false);
+ break;
+ case "input_boolean/create":
+ var createInputBooleanCommand =
+ JsonSerializer.Deserialize(
+ new ReadOnlySpan(buffer, 0, result.Count))
+ ?? throw new ApplicationException("Unexpected not able to deserialize input boolean");
+ var inputBoolean = new InputBoolean { Id = createInputBooleanCommand.Name, Name = createInputBooleanCommand.Name };
+ logger.LogInformation("Creating input_boolean {InputBoolean}", inputBoolean);
+ _inputBooleans.Add(inputBoolean);
+ var inputBooleanCreateResultMsg = Messages.ResultMsg(hassMessage.Id);
+ await SendWebsocketMessage(webSocket, inputBooleanCreateResultMsg).ConfigureAwait(false);
+ break;
+ case "input_boolean/list":
+ var inputBooleans = _inputBooleans.ToArray();
+ var jsonInputBooleans = JsonSerializer.Serialize(inputBooleans);
+ var jsonDoc = JsonDocument.Parse(jsonInputBooleans);
+ var response = new HassMessage {
+ Id = hassMessage.Id,
+ Type = "result",
+ Success = true,
+ ResultElement = jsonDoc.RootElement };
+ var responseMsg = JsonSerializer.Serialize(response);
+ logger.LogInformation("Sending input_boolean/list response {Response}", response);
+ await SendWebsocketMessage(webSocket, responseMsg).ConfigureAwait(false);
+ break;
+ case "start_performance_test":
+ await SendWebsocketMessage(webSocket, Messages.ResultMsg(hassMessage.Id)).ConfigureAwait(false);
+ _perfTestTask = StartPerformanceTest(webSocket);
+ break;
+
+ default:
+ throw new ApplicationException($"Unknown message type {hassMessage.Type}");
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ logger.LogDebug("Cancelled operation");
+ await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Normal", CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (Exception e)
+ {
+ logger.LogError(e, "Failed to process websocket");
+ }
+ finally
+ {
+ try
+ {
+ await SendCorrectCloseFrameToRemoteWebSocket(webSocket).ConfigureAwait(false);
+ }
+ catch
+ {
+ // Just fail silently
+ }
+ }
+ logger.LogInformation("Websocket processing done");
+ }
+
+ ///
+ /// Starts the performance test, sends 1 000 000 state changes as fast as possible
+ /// and then stops the performance test.
+ /// Todo: Make the number of sent messages configurable
+ ///
+ private async Task StartPerformanceTest(WebSocket webSocket)
+ {
+ // Make a small delay to make sure all websocket messages are processed
+ // Not worth it to make something more fancy since this is not run in the CI
+ await Task.Delay(1000).ConfigureAwait(false);
+
+ var subscription = _eventSubscriptions.FirstOrDefault();
+ if (subscription == 0)
+ {
+ logger.LogWarning("No subscriptions found, cannot start performance test");
+ return;
+ }
+
+ logger.LogInformation("Starting performance test");
+
+ var eventMessage = Messages.EventResultMsgMultiple(subscription, "on", "off");
+ for (var i = 0; i < 1000000; i++)
+ /*for (var i = 0; i < 5; i++)*/
+ {
+ await SendWebsocketMessage(webSocket, eventMessage).ConfigureAwait(false);
+ }
+
+ // Sends the last state change with state "stop" to make the client stop the performance test
+ eventMessage = Messages.EventResultMsg(subscription, "on", "stop");
+ await SendWebsocketMessage(webSocket, eventMessage).ConfigureAwait(false);
+ logger.LogInformation("Performance test done");
+ }
+
+ ///
+ /// Closes correctly the websocket depending on websocket state
+ ///
+ ///
+ ///
+ /// Closing a websocket has special handling. When the client
+ /// wants to close it calls CloseAsync and the websocket takes
+ /// care of the proper close handling.
+ ///
+ ///
+ /// If the remote websocket wants to close the connection dotnet
+ /// implementation requires you to use CloseOutputAsync instead.
+ ///
+ ///
+ /// We do not want to cancel operations until we get closed state
+ /// this is why own timer cancellation token is used and we wait
+ /// for correct state before returning and disposing any connections
+ ///
+ ///
+ private static async Task SendCorrectCloseFrameToRemoteWebSocket(WebSocket ws)
+ {
+ using var timeout = new CancellationTokenSource(DefaultTimeOut);
+
+ try
+ {
+ switch (ws.State)
+ {
+ case WebSocketState.CloseReceived:
+ {
+ // after this, the socket state which change to CloseSent
+ await ws.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "Closing", timeout.Token)
+ .ConfigureAwait(false);
+ // now we wait for the server response, which will close the socket
+ while (ws.State != WebSocketState.Closed && !timeout.Token.IsCancellationRequested)
+ await Task.Delay(100).ConfigureAwait(false);
+ break;
+ }
+ case WebSocketState.Open:
+ {
+ // Do full close
+ await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", timeout.Token)
+ .ConfigureAwait(false);
+ if (ws.State != WebSocketState.Closed)
+ throw new ApplicationException("Expected the websocket to be closed!");
+ break;
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // normal upon task/token cancellation, disregard
+ }
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cancelSource.CancelAsync();
+ if (_perfTestTask != null && !_perfTestTask.IsCompleted)
+ {
+ await _perfTestTask;
+ }
+ _cancelSource.Dispose();
+ }
+}
diff --git a/tests/Performance/PerfServer/Internal/WebHostExtensions.cs b/tests/Performance/PerfServer/Internal/WebHostExtensions.cs
new file mode 100644
index 00000000..3b1cc5ae
--- /dev/null
+++ b/tests/Performance/PerfServer/Internal/WebHostExtensions.cs
@@ -0,0 +1,67 @@
+using Serilog;
+
+namespace NetDaemon.Tests.Performance;
+
+internal static class WebHostExtensions
+{
+ public static WebApplicationBuilder UsePerfServerSettings(this WebApplicationBuilder builder)
+ {
+ builder.Host
+ .UseSerilog((context, provider, logConfig) =>
+ {
+ logConfig.ReadFrom.Configuration(context.Configuration);
+ });
+
+ builder.Services.AddScoped();
+
+ return builder;
+ }
+
+ public static void AddPerfServer(this WebApplication app)
+ {
+ app.UseWebSockets();
+
+ // NetDaemon is checking the state of input_boolean so let's fake a response
+ app.MapGet("/api/states/{entityId}", (string entityId) =>
+ {
+ if (entityId.StartsWith("input_boolean.", StringComparison.Ordinal))
+ {
+ var id = entityId[14..];
+ var inputBoolean = PerfServerStartup._inputBooleans.FirstOrDefault(n => n.Name == id);
+ if (inputBoolean != null)
+ {
+ return Results.Ok(new EntityState
+ {
+ EntityId = entityId,
+ State = "on",
+ });
+ }
+ }
+ return Results.NotFound();
+ });
+
+ app.Use(async (context, next) =>
+ {
+ if (context.Request.Path == "/api/websocket")
+ {
+ if (context.WebSockets.IsWebSocketRequest)
+ {
+ var perfServer = context.RequestServices.GetRequiredService();
+ var webSocket = await context.WebSockets.AcceptWebSocketAsync();
+ await perfServer.ProcessWebsocket(webSocket);
+ return;
+ }
+ else
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ }
+ }
+ else
+ {
+ await next(context);
+ }
+
+ });
+ app.UseRouting();
+ }
+}
diff --git a/tests/Performance/PerfServer/PerfServer.csproj b/tests/Performance/PerfServer/PerfServer.csproj
new file mode 100644
index 00000000..2a637e54
--- /dev/null
+++ b/tests/Performance/PerfServer/PerfServer.csproj
@@ -0,0 +1,21 @@
+
+
+
+ Exe
+ net9.0
+ enable
+ enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Performance/PerfServer/Program.cs b/tests/Performance/PerfServer/Program.cs
new file mode 100644
index 00000000..bb78426d
--- /dev/null
+++ b/tests/Performance/PerfServer/Program.cs
@@ -0,0 +1,22 @@
+using NetDaemon.Tests.Performance;
+
+#pragma warning disable CA1812
+
+
+var builder = WebApplication.CreateBuilder(args);
+
+var app = builder.UsePerfServerSettings()
+ .Build();
+
+app.AddPerfServer();
+
+try
+{
+ await app.RunAsync().ConfigureAwait(false);
+}
+catch (OperationCanceledException) { } // Ignore
+catch (Exception e)
+{
+ Console.WriteLine($"Failed to start host... {e}");
+ throw;
+}
diff --git a/tests/Performance/PerfServer/appsettings.json b/tests/Performance/PerfServer/appsettings.json
new file mode 100644
index 00000000..8405c6ad
--- /dev/null
+++ b/tests/Performance/PerfServer/appsettings.json
@@ -0,0 +1,29 @@
+{
+ "Kestrel": {
+ "EndPoints": {
+ "Http": {
+ "Url": "http://localhost:8002"
+ }
+ }
+ },
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Debug",
+ "Override": {
+ "System": "Information",
+ "Microsoft": "Information",
+ "System.Net.Http.HttpClient": "Warning",
+ "daemonapp.app": "Verbose"
+ }
+ },
+ "WriteTo": [
+ {
+ "Name": "Console",
+ "Args": {
+ "theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console",
+ "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext:l}: {Message:lj}{NewLine}{Exception}"
+ }
+ }
+ ]
+ }
+}
diff --git a/tests/Performance/README.md b/tests/Performance/README.md
new file mode 100644
index 00000000..4fc05d74
--- /dev/null
+++ b/tests/Performance/README.md
@@ -0,0 +1,38 @@
+# Performance tests
+
+The following directory contains a simple performance test for NetDaemon runtime.
+
+It has two parts, one fake Home Assistant server that fakes on-boarding and waits for command to start pushing state changes as fast as possible.
+The other part is the NetDaemon runtime with three identical apps that listens to the same event state changes as calculate the performance of
+number of messages per second processed in each app.
+
+## How to run the performance test
+
+Open two terminal windows. `cd` to the root of the repository in both terminals.
+
+1. Start the fake Home Assistant fake server in `tests/Performance/PerfServer`:
+
+```bash
+
+cd tests/Performance/PerfServer
+dotnet run -c Release
+
+```
+
+2. Start the NetDaemon runtime in `tests/Performance/PerfClient`:
+
+```bash
+cd tests/Performance/PerfClient
+dotnet run -c Release
+```
+
+## Expected output
+
+The performance test will start and you will see the number of messages processed per second in the console after one million messages.
+
+## Future improvements
+
+These tests are very basic MVP and can be improved in many ways. Example the tests can be dockerized and run in a CI/CD pipeline to ensure that performance is not degraded over time.
+
+
+