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

net-daemon / netdaemon / 29408474270

15 Jul 2026 10:32AM UTC coverage: 85.051% (+1.5%) from 83.551%
29408474270

Pull #1385

github

web-flow
Merge 142d16019 into bf6c3500c
Pull Request #1385: MQTT Upgrade - attempt 2

977 of 1293 branches covered (75.56%)

Branch coverage included in aggregate %.

220 of 248 new or added lines in 5 files covered. (88.71%)

4 existing lines in 3 files now uncovered.

3660 of 4159 relevant lines covered (88.0%)

137.27 hits per line

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

94.24
/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();
47✔
18

19
    private readonly Subject<HassMessage> _hassMessageSubject = new();
47✔
20
    private readonly ConcurrentDictionary<int, TaskCompletionSource<HassMessage>> _pendingResults = new();
47✔
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);
47✔
26
    private int _messageId = 1;
47✔
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(
47✔
40
        ILogger<IHomeAssistantConnection> logger,
47✔
41
        IWebSocketClientTransportPipeline pipeline,
47✔
42
        IHomeAssistantApiManager apiManager,
47✔
43
        TimeProvider? timeProvider = null,
47✔
44
        TimeSpan? waitForResultTimeout = null
47✔
45
    )
47✔
46
    {
47
        _transportPipeline = pipeline ?? throw new ArgumentNullException(nameof(pipeline));
47!
48
        _apiManager = apiManager;
47✔
49
        _logger = logger;
47✔
50
        _timeProvider = timeProvider ?? TimeProvider.System;
47✔
51
        _waitForResultTimeout = waitForResultTimeout ?? DefaultWaitForResultTimeout;
47✔
52

53
        _resultMessageHandler = new ResultMessageHandler(_logger);
47✔
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>>() =>
47✔
58
            await SubscribeToHomeAssistantEventsInternalAsync(null, _internalCancelSource.Token));
60✔
59

60
        if (_transportPipeline.WebSocketState != WebSocketState.Open)
47✔
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),
92✔
65
            TaskCreationOptions.LongRunning);
46✔
66
    }
46✔
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))
37✔
73
            return await _lazyAllEventsObservable.Value;
34✔
74

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

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

86
        var result = await SendCommandAndReturnHassMessageResponseAsync(new SubscribeEventCommand(),
16✔
87
            combinedTokenSource.Token).ConfigureAwait(false);
16✔
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)
2,309!
91
            .Select(n => n.Event!);
1,574✔
92

93
        return observableResult;
16✔
94
    }
16✔
95

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

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

107
    public async Task SendCommandAsync<T>(T command, CancellationToken cancelToken) where T : CommandMessage
108
    {
109
        var returnMessageTask = await SendCommandAsyncInternal(command, cancelToken);
27✔
110
        _resultMessageHandler.HandleResult(
26✔
111
            WaitForPendingResultAsync(command, returnMessageTask, _internalCancelSource.Token),
26✔
112
            command);
26✔
113
    }
26✔
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);
140✔
119

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

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

129
        // The SendCommandsAndReturnHAssMessageResponse will throw if not successful so just ignore errors here
130
        return hassMessage?.ResultElement;
144!
131
    }
144✔
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);
174✔
138
        var result = await WaitForPendingResultAsync(command, resultMessageTask, cancelToken).ConfigureAwait(false);
172✔
139

140
        if (result.Success ?? false)
169✔
141
            return result;
167✔
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
    }
167✔
147

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

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

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

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

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

179
    public Task<T?> GetApiCallAsync<T>(string apiPath, CancellationToken cancelToken)
180
    {
181
        ObjectDisposedException.ThrowIf(_isDisposed, this);
62✔
182
        return _apiManager.GetApiCallAsync<T>(apiPath, cancelToken);
61✔
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);
201✔
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);
200✔
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;
200✔
205

206
            var resultEvent = new TaskCompletionSource<HassMessage>(
200✔
207
                TaskCreationOptions.RunContinuationsAsynchronously);
200✔
208
            if (!_pendingResults.TryAdd(command.Id, resultEvent))
200!
209
                throw new InvalidOperationException($"A command with id {command.Id} is already pending");
×
210

211
            try
212
            {
213
                await _transportPipeline.SendMessageAsync(command, cancelToken);
200✔
214
            }
198✔
215
            catch (Exception)
2✔
216
            {
217
                _pendingResults.TryRemove(command.Id, out _);
2✔
218
                throw;
2✔
219
            }
220

221
            return resultEvent.Task;
198✔
222
        }
223
        finally
224
        {
225
            _messageIdSemaphore.Release();
200✔
226
        }
227
    }
198✔
228

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

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

285
    private void HandleIncomingMessage(HassMessage message)
286
    {
287
        if (message.Type == "result" && _pendingResults.TryRemove(message.Id, out var completionSource))
428✔
288
            completionSource.TrySetResult(message);
193✔
289

290
        _hassMessageSubject.OnNext(message);
428✔
291
    }
428✔
292

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

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

304
    private void CancelPendingResults()
305
    {
306
        foreach (var pendingResult in _pendingResults)
162✔
307
        {
308
            if (_pendingResults.TryRemove(pendingResult.Key, out var completionSource))
2✔
309
                completionSource.TrySetCanceled();
2✔
310
        }
311
    }
79✔
312

313
    private Task CloseAsync()
314
    {
315
        return _transportPipeline.CloseAsync();
46✔
316
    }
317
}
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