• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

net-daemon / netdaemon / 29074187479

10 Jul 2026 06:34AM UTC coverage: 83.642% (+0.3%) from 83.301%
29074187479

Pull #1389

github

web-flow
Merge 8f16545ac into b913b8f1e
Pull Request #1389: [codex] Improve high-event hot paths

901 of 1217 branches covered (74.03%)

Branch coverage included in aggregate %.

84 of 85 new or added lines in 3 files covered. (98.82%)

3 existing lines in 1 file now uncovered.

3440 of 3973 relevant lines covered (86.58%)

982.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.29
/src/Client/NetDaemon.HassClient/Internal/HomeAssistantConnection.cs
1
using System.Collections.Concurrent;
2

3
namespace NetDaemon.Client.Internal;
4

5
internal class HomeAssistantConnection : IHomeAssistantConnection, IHomeAssistantHassMessages
6
{
7
    #region -- private declarations -
8

9
    private volatile bool _isDisposed;
10

11
    private readonly ILogger<IHomeAssistantConnection> _logger;
12
    private readonly IWebSocketClientTransportPipeline _transportPipeline;
13
    private readonly IHomeAssistantApiManager _apiManager;
14
    private readonly ResultMessageHandler _resultMessageHandler;
15
    private readonly TimeProvider _timeProvider;
16
    private readonly TimeSpan _waitForResultTimeout;
17
    private readonly CancellationTokenSource _internalCancelSource = new();
45✔
18

19
    private readonly Subject<HassMessage> _hassMessageSubject = new();
45✔
20
    private readonly ConcurrentDictionary<int, TaskCompletionSource<HassMessage>> _pendingResults = new();
45✔
21
    private readonly Task _handleNewMessagesTask;
22

23
    private static readonly TimeSpan DefaultWaitForResultTimeout = TimeSpan.FromSeconds(20);
3✔
24

25
    private readonly SemaphoreSlim _messageIdSemaphore = new(1, 1);
45✔
26
    private int _messageId = 1;
45✔
27
    private readonly AsyncLazy<IObservable<HassEvent>> _lazyAllEventsObservable;
28

29
    #endregion
30

31
    /// <summary>
32
    ///     Default constructor
33
    /// </summary>
34
    /// <param name="logger">A logger instance</param>
35
    /// <param name="pipeline">The pipeline to use for websocket communication</param>
36
    /// <param name="apiManager">The api manager</param>
37
    /// <param name="timeProvider">The time provider used when waiting for command results</param>
38
    /// <param name="waitForResultTimeout">The maximum time to wait for a command result</param>
39
    public HomeAssistantConnection(
45✔
40
        ILogger<IHomeAssistantConnection> logger,
45✔
41
        IWebSocketClientTransportPipeline pipeline,
45✔
42
        IHomeAssistantApiManager apiManager,
45✔
43
        TimeProvider? timeProvider = null,
45✔
44
        TimeSpan? waitForResultTimeout = null
45✔
45
    )
45✔
46
    {
47
        _transportPipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
45!
48
        _apiManager = apiManager;
45✔
49
        _logger = logger;
45✔
50
        _timeProvider = timeProvider ?? TimeProvider.System;
45✔
51
        _waitForResultTimeout = waitForResultTimeout ?? DefaultWaitForResultTimeout;
45✔
52

53
        _resultMessageHandler = new ResultMessageHandler(_logger);
45✔
54

55
        // We lazily cache same observable for all events. There are no reason we should use multiple subscriptions
56
        // to all events. If people wants that they can provide a "*" type and get the same thing
57
        _lazyAllEventsObservable = new AsyncLazy<IObservable<HassEvent>>(async Task<IObservable<HassEvent>>() =>
45✔
58
            await SubscribeToHomeAssistantEventsInternalAsync(null, _internalCancelSource.Token));
56✔
59

60
        if (_transportPipeline.WebSocketState != WebSocketState.Open)
45✔
61
            throw new ApplicationException(
1✔
62
                $"Expected WebSocket state 'Open' got '{_transportPipeline.WebSocketState}'");
1✔
63

64
        _handleNewMessagesTask = Task.Factory.StartNew(async () => await HandleNewMessages().ConfigureAwait(false),
88✔
65
            TaskCreationOptions.LongRunning);
44✔
66
    }
44✔
67

68
    public async Task<IObservable<HassEvent>> SubscribeToHomeAssistantEventsAsync(string? eventType,
69
        CancellationToken cancelToken)
70
    {
71
        // When subscribe all events, optimize using the same IObservable<HassEvent> if we subscribe multiple times
72
        if (string.IsNullOrEmpty(eventType))
31✔
73
            return await _lazyAllEventsObservable.Value;
28✔
74

75
        return await SubscribeToHomeAssistantEventsInternalAsync(eventType, cancelToken);
3✔
76
    }
31✔
77

78
    private async Task<IObservable<HassEvent>> SubscribeToHomeAssistantEventsInternalAsync(string? eventType,
79
        CancellationToken cancelToken)
80
    {
81
        var combinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
14✔
82
            cancelToken,
14✔
83
            _internalCancelSource.Token
14✔
84
        );
14✔
85

86
        var result = await SendCommandAndReturnHassMessageResponseAsync(new SubscribeEventCommand(),
14✔
87
            combinedTokenSource.Token).ConfigureAwait(false);
14✔
88

89
        // The id if the message we used to subscribe should be used as the filter for the event messages
90
        var observableResult = _hassMessageSubject.Where(n => n.Type == "event" && n.Id == result?.Id)
1,491!
91
            .Select(n => n.Event!);
924✔
92

93
        return observableResult;
14✔
94
    }
14✔
95

96
    public async Task WaitForConnectionToCloseAsync(CancellationToken cancelToken)
97
    {
98
        var combinedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
12✔
99
            cancelToken,
12✔
100
            _internalCancelSource.Token
12✔
101
        );
12✔
102

103
        // Just wait for token source (internal och provided one)
104
        await combinedTokenSource.Token.AsTask().ConfigureAwait(false);
12✔
105
    }
×
106

107
    public async Task SendCommandAsync<T>(T command, CancellationToken cancelToken) where T : CommandMessage
108
    {
109
        var returnMessageTask = await SendCommandAsyncInternal(command, cancelToken);
19✔
110
        _resultMessageHandler.HandleResult(
18✔
111
            WaitForPendingResultAsync(command, returnMessageTask, _internalCancelSource.Token),
18✔
112
            command);
18✔
113
    }
18✔
114

115
    public async Task<TResult?> SendCommandAndReturnResponseAsync<T, TResult>(T command, CancellationToken cancelToken)
116
        where T : CommandMessage
117
    {
118
        var result = await SendCommandAndReturnResponseRawAsync(command, cancelToken).ConfigureAwait(false);
118✔
119

120
        return result is not null ? result.Value.Deserialize<TResult>() : default;
117✔
121
    }
117✔
122

123
    public async Task<JsonElement?> SendCommandAndReturnResponseRawAsync<T>(T command, CancellationToken cancelToken)
124
        where T : CommandMessage
125
    {
126
        var hassMessage =
123✔
127
            await SendCommandAndReturnHassMessageResponseAsync(command, cancelToken).ConfigureAwait(false);
123✔
128

129
        // The SendCommandsAndReturnHAssMessageResponse will throw if not successful so just ignore errors here
130
        return hassMessage?.ResultElement;
122!
131
    }
122✔
132

133
    public async Task<HassMessage?> SendCommandAndReturnHassMessageResponseAsync<T>(T command,
134
        CancellationToken cancelToken)
135
        where T : CommandMessage
136
    {
137
        var resultMessageTask = await SendCommandAsyncInternal(command, cancelToken);
150✔
138
        var result = await WaitForPendingResultAsync(command, resultMessageTask, cancelToken).ConfigureAwait(false);
148✔
139

140
        if (result.Success ?? false)
145✔
141
            return result;
143✔
142

143
        // Non successful command should throw exception
144
        throw new InvalidOperationException(
2✔
145
            $"Failed command ({command.Type}) error: {result.Error}.  Sent command is {command.ToJsonElement()}");
2✔
146
    }
143✔
147

148
    public async ValueTask DisposeAsync()
149
    {
150
        if (_isDisposed) return;
46✔
151
        _isDisposed = true;
44✔
152

153
        try
154
        {
155
            await CloseAsync().ConfigureAwait(false);
44✔
156
        }
43✔
157
        catch (Exception e)
1✔
158
        {
159
            _logger.LogDebug(e, "Failed to close HomeAssistantConnection");
1✔
160
        }
1✔
161

162
        if (!_internalCancelSource.IsCancellationRequested)
44✔
163
            await _internalCancelSource.CancelAsync();
28✔
164
        CancelPendingResults();
44✔
165

166
        // Gracefully wait for task or timeout
167
        await Task.WhenAny(
44✔
168
            _handleNewMessagesTask,
44✔
169
            Task.Delay(5000)
44✔
170
        ).ConfigureAwait(false);
44✔
171

172
        await _transportPipeline.DisposeAsync().ConfigureAwait(false);
44✔
173
        _internalCancelSource.Dispose();
44✔
174
        _hassMessageSubject.Dispose();
44✔
175
        await _resultMessageHandler.DisposeAsync();
44✔
176
        _messageIdSemaphore.Dispose();
44✔
177
    }
45✔
178

179
    public Task<T?> GetApiCallAsync<T>(string apiPath, CancellationToken cancelToken)
180
    {
181
        ObjectDisposedException.ThrowIf(_isDisposed, this);
34✔
182
        return _apiManager.GetApiCallAsync<T>(apiPath, cancelToken);
33✔
183
    }
184

185
    public Task<T?> PostApiCallAsync<T>(string apiPath, CancellationToken cancelToken, object? data = null)
186
    {
187
        ObjectDisposedException.ThrowIf(_isDisposed, this);
2✔
188
        return _apiManager.PostApiCallAsync<T>(apiPath, cancelToken, data);
1✔
189
    }
190

191
    public IObservable<HassMessage> OnHassMessage => _hassMessageSubject;
4✔
192

193
    private async Task<Task<HassMessage>> SendCommandAsyncInternal<T>(T command, CancellationToken cancelToken) where T : CommandMessage
194
    {
195
        ObjectDisposedException.ThrowIf(_isDisposed, this);
169✔
196

197
        // The semaphore can fail to be taken in rare cases so we need
198
        // to keep this out of the try/finally block so it will not be released
199
        await _messageIdSemaphore.WaitAsync(cancelToken).ConfigureAwait(false);
168✔
200
        try
201
        {
202
            // We need to make sure messages to HA are send with increasing Ids therefore we need to synchronize
203
            // increasing the messageId and Sending the message
204
            command.Id = ++_messageId;
168✔
205

206
            // Complete result waiters directly from the receive loop instead of creating
207
            // one filtered Rx subscription per command.
208
            var resultEvent = new TaskCompletionSource<HassMessage>(
168✔
209
                TaskCreationOptions.RunContinuationsAsynchronously);
168✔
210
            if (!_pendingResults.TryAdd(command.Id, resultEvent))
168!
NEW
211
                throw new InvalidOperationException($"A command with id {command.Id} is already pending");
×
212

213
            try
214
            {
215
                await _transportPipeline.SendMessageAsync(command, cancelToken);
168✔
216
            }
166✔
217
            catch (Exception)
2✔
218
            {
219
                _pendingResults.TryRemove(command.Id, out _);
2✔
220
                throw;
2✔
221
            }
222

223
            return resultEvent.Task;
166✔
224
        }
225
        finally
226
        {
227
            _messageIdSemaphore.Release();
168✔
228
        }
229
    }
166✔
230

231
    private async Task HandleNewMessages()
232
    {
233
        try
234
        {
235
            while (!_internalCancelSource.IsCancellationRequested)
243✔
236
            {
237
                var msg = await _transportPipeline.GetNextMessagesAsync<HassMessage>(_internalCancelSource.Token)
241✔
238
                    .ConfigureAwait(false);
241✔
239
                try
240
                {
241
                    foreach (var obj in msg)
1,002✔
242
                    {
243
                        HandleIncomingMessage(obj);
302✔
244
                    }
245
                }
199✔
246
                catch (Exception e)
×
247
                {
248
                    _logger.LogError(e, "Failed processing new message from Home Assistant");
×
249
                }
×
250
            }
251
        }
2✔
252
        catch (OperationCanceledException)
8✔
253
        {
254
            // Normal case just exit
255
        }
8✔
256
        finally
257
        {
258
            _logger.LogTrace("Stop processing new messages");
31✔
259
            CancelPendingResults();
31✔
260
            // make sure we always cancel any blocking operations
261
            if (!_internalCancelSource.IsCancellationRequested)
31✔
262
                await _internalCancelSource.CancelAsync();
16✔
263
        }
264
    }
10✔
265

266
    private async Task<HassMessage> WaitForPendingResultAsync(CommandMessage command,
267
        Task<HassMessage> resultMessageTask,
268
        CancellationToken cancelToken)
269
    {
270
        try
271
        {
272
            return await resultMessageTask.WaitAsync(_waitForResultTimeout, _timeProvider, cancelToken)
166✔
273
                .ConfigureAwait(false);
166✔
274
        }
275
        catch (TimeoutException)
1✔
276
        {
277
            CancelPendingResult(command.Id, CancellationToken.None);
1✔
278
            throw;
1✔
279
        }
280
        catch (OperationCanceledException)
4✔
281
        {
282
            CancelPendingResult(command.Id, cancelToken);
4✔
283
            throw;
4✔
284
        }
285
    }
161✔
286

287
    private void HandleIncomingMessage(HassMessage message)
288
    {
289
        if (message.Type == "result" && _pendingResults.TryRemove(message.Id, out var completionSource))
302✔
290
            completionSource.TrySetResult(message);
161✔
291

292
        _hassMessageSubject.OnNext(message);
302✔
293
    }
302✔
294

295
    private void CancelPendingResult(int messageId, CancellationToken cancelToken = default)
296
    {
297
        if (!_pendingResults.TryRemove(messageId, out var completionSource))
5✔
298
            return;
2✔
299

300
        if (cancelToken.CanBeCanceled)
3✔
301
            completionSource.TrySetCanceled(cancelToken);
2✔
302
        else
303
            completionSource.TrySetCanceled(CancellationToken.None);
1✔
304
    }
1✔
305

306
    private void CancelPendingResults()
307
    {
308
        foreach (var pendingResult in _pendingResults)
154✔
309
        {
310
            if (_pendingResults.TryRemove(pendingResult.Key, out var completionSource))
2✔
311
                completionSource.TrySetCanceled();
2✔
312
        }
313
    }
75✔
314

315
    private Task CloseAsync()
316
    {
317
        return _transportPipeline.CloseAsync();
44✔
318
    }
319
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc