OSDP.Net 5.2.1
A .NET library for implementing the Open Supervised Device Protocol (OSDP)
Loading...
Searching...
No Matches
LineQualityResponder.cs
1using System;
2using System.Collections.ObjectModel;
3using System.Linq;
4using System.Threading;
5using System.Threading.Tasks;
6using Microsoft.Extensions.Logging;
11
13{
31 public sealed class LineQualityResponder
32 {
33 private static readonly TimeSpan IdleReadWait = TimeSpan.FromSeconds(1);
34 private static readonly TimeSpan ReconnectDelay = TimeSpan.FromMilliseconds(250);
35
41 private const int ReadFaultsBeforeReconnect = 3;
42
43 private readonly IRetunableOsdpConnection _connection;
44 private readonly byte _address;
45 private readonly ILogger _logger;
46
47 private DateTime _lastValidCommand = DateTime.MinValue;
48 private int _consecutiveReadFaults;
49
59 byte address = LineQualityProtocol.TestAddress, ILoggerFactory loggerFactory = null)
60 {
61 _connection = connection ?? throw new ArgumentNullException(nameof(connection));
62 _address = address;
63 _logger = loggerFactory?.CreateLogger<LineQualityResponder>();
64 }
65
76 public TimeSpan AutoRevertTimeout { get; set; } = TimeSpan.FromSeconds(30);
77
82 public TimeSpan BaudRateSettleDelay { get; set; } = TimeSpan.FromMilliseconds(100);
83
85 public int CurrentBaudRate => _connection.BaudRate;
86
90 public event EventHandler<LineQualityExchangeEventArgs> ExchangeCompleted;
91
101 public event EventHandler<LineQualityBaudRateChangedEventArgs> BaudRateChanged;
102
107 public async Task RunAsync(CancellationToken token)
108 {
109 if (!_connection.IsOpen)
110 {
111 await _connection.Open().ConfigureAwait(false);
112 }
113
114 // An ACU discards its buffers before each command because it drives a strict
115 // command/reply cycle. A responder must not: the next command can already be arriving
116 // while this reply is being written.
117 _connection.DiscardBuffersBeforeWrite = false;
118 _lastValidCommand = DateTime.UtcNow;
119
120 _logger?.LogInformation(
121 "Line quality responder listening at address {Address} on {Connection}, {BaudRate} baud",
122 _address, _connection, _connection.BaudRate);
123
124 while (!token.IsCancellationRequested)
125 {
126 var command = await ReadCommand(token).ConfigureAwait(false);
127 if (command == null)
128 {
129 RevertBaudRateIfIdle();
130 continue;
131 }
132
133 _lastValidCommand = DateTime.UtcNow;
134
135 try
136 {
137 await Respond(command, token).ConfigureAwait(false);
138 }
139 catch (OperationCanceledException)
140 {
141 throw;
142 }
143 catch (Exception exception)
144 {
145 _logger?.LogError(exception, "Failed to reply to a line quality command");
146 }
147 }
148 }
149
154 private async Task<IncomingMessage> ReadCommand(CancellationToken token)
155 {
156 var buffer = new Collection<byte>();
157
158 if (!await WaitForStartOfMessage(buffer, token).ConfigureAwait(false))
159 {
160 return null;
161 }
162
163 if (!await Bus.WaitForMessageLength(_connection, buffer, token).ConfigureAwait(false))
164 {
165 return null;
166 }
167
168 if (!await Bus.WaitForRestOfMessage(_connection, buffer, Bus.ExtractMessageLength(buffer), token)
169 .ConfigureAwait(false))
170 {
171 return null;
172 }
173
174 IncomingMessage command;
175 try
176 {
177 command = new IncomingMessage(buffer.ToArray(), ClearTextChannel.Instance);
178 }
179 catch (Exception exception)
180 {
181 // A frame mangled badly enough to fail parsing is indistinguishable from line
182 // noise. Stay silent and let the controller record a timeout.
183 _logger?.LogDebug(exception, "Discarded an unparsable frame");
184 return null;
185 }
186
187 if (!command.IsDataCorrect)
188 {
189 _logger?.LogDebug("Discarded a frame that failed its integrity check");
190 return null;
191 }
192
193 return command.Address == _address ? command : null;
194 }
195
207 private async Task<bool> WaitForStartOfMessage(Collection<byte> buffer, CancellationToken token)
208 {
209 var deadline = DateTime.UtcNow + IdleReadWait;
210 var readBuffer = new byte[1];
211
212 while (DateTime.UtcNow < deadline)
213 {
214 token.ThrowIfCancellationRequested();
215
216 int bytesRead;
217 try
218 {
219 bytesRead = await ReadWithTimeout(readBuffer, IdleReadWait, token).ConfigureAwait(false);
220 }
221 catch (Exception exception)
222 {
223 await HandleReadFault(exception, token).ConfigureAwait(false);
224 return false;
225 }
226
227 _consecutiveReadFaults = 0;
228
229 // A genuinely idle line, which is the normal case between commands.
230 if (bytesRead == 0) return false;
231
232 if (readBuffer[0] != Message.StartOfMessage) continue;
233
234 buffer.Add(readBuffer[0]);
235 return true;
236 }
237
238 return false;
239 }
240
253 private async Task<int> ReadWithTimeout(byte[] buffer, TimeSpan timeout, CancellationToken token)
254 {
255 using var timeoutSource = new CancellationTokenSource(timeout);
256 using var linkedSource =
257 CancellationTokenSource.CreateLinkedTokenSource(token, timeoutSource.Token);
258
259 try
260 {
261 return await _connection.ReadAsync(buffer, linkedSource.Token).ConfigureAwait(false);
262 }
263 catch (Exception exception) when (timeoutSource.IsCancellationRequested &&
264 IsTimeoutSignal(exception))
265 {
266 // Our own read timeout expired: the line was simply quiet. Cancellation of the run
267 // still propagates; anything else is a genuine fault and is left to the caller.
268 token.ThrowIfCancellationRequested();
269 return 0;
270 }
271 }
272
276 private static bool IsTimeoutSignal(Exception exception) =>
277 exception is OperationCanceledException || exception is TimeoutException;
278
279 private async Task HandleReadFault(Exception exception, CancellationToken token)
280 {
281 // A read that fails while the responder is being shut down is a consequence of the
282 // shutdown, not a fault worth reopening the port over.
283 if (token.IsCancellationRequested) return;
284
285 _consecutiveReadFaults++;
286
287 _logger?.LogWarning(exception, "Serial read failed ({Count} in a row)",
288 _consecutiveReadFaults);
289
290 if (_consecutiveReadFaults < ReadFaultsBeforeReconnect) return;
291
292 _consecutiveReadFaults = 0;
293 await ReopenConnection(token).ConfigureAwait(false);
294 }
295
299 private async Task ReopenConnection(CancellationToken token)
300 {
301 int baudRate = _connection.BaudRate;
302
303 try
304 {
305 await _connection.Close().ConfigureAwait(false);
306 await Task.Delay(ReconnectDelay, token).ConfigureAwait(false);
307 await _connection.Open().ConfigureAwait(false);
308
309 _connection.SetBaudRate(baudRate);
310 _connection.DiscardBuffersBeforeWrite = false;
311
312 // Warning rather than Information: reopening a port is an abnormal event, and a
313 // consumer that filters to Warning is exactly the one that needs to see it.
314 _logger?.LogWarning(
315 "Reopened the connection at {BaudRate} baud to clear repeated read failures",
316 baudRate);
317 }
318 catch (OperationCanceledException)
319 {
320 throw;
321 }
322 catch (Exception exception)
323 {
324 _logger?.LogError(exception, "Could not reopen the connection after read failures");
325 }
326 }
327
328 private async Task Respond(IncomingMessage command, CancellationToken token)
329 {
330 if (command.Type != (byte)CommandType.ManufacturerSpecific)
331 {
332 await SendReply(command, new Nak(ErrorCode.UnknownCommandCode), token).ConfigureAwait(false);
333 return;
334 }
335
336 var payload = command.Payload.AsSpan();
337
338 if (EchoRequest.TryParse(payload, out var echoRequest))
339 {
340 await HandleEcho(command, echoRequest, token).ConfigureAwait(false);
341 return;
342 }
343
344 if (BaudRateChange.TryParse(payload, out var baudRateChange))
345 {
346 await HandleBaudRateChange(command, baudRateChange, token).ConfigureAwait(false);
347 return;
348 }
349
350 await SendReply(command, new Nak(ErrorCode.UnknownCommandCode), token).ConfigureAwait(false);
351 }
352
353 private async Task HandleEcho(IncomingMessage command, EchoRequest request, CancellationToken token)
354 {
355 PayloadData reply;
356
357 if (!LineQualityProtocol.IsSupportedPattern(request.Pattern))
358 {
359 reply = new EchoResponse(request.SequenceNumber, EchoStatus.UnsupportedPattern);
360 }
361 else if (request.DeclaredPayloadLength > LineQualityProtocol.MaxPayloadLength)
362 {
363 reply = new EchoResponse(request.SequenceNumber, EchoStatus.LengthError);
364 }
365 else if (!request.IsLengthConsistent)
366 {
367 // The header promised more data than the frame carried, yet the CRC passed. The
368 // frame is internally inconsistent rather than corrupted in transit.
369 reply = new Nak(ErrorCode.InvalidCommandLength);
370 }
371 else
372 {
373 // Echo the bytes as received. Regenerating them from the pattern would repair a
374 // corrupted payload on the way back and hide the very errors being measured.
375 reply = new EchoResponse(request.SequenceNumber, request.Data);
376 }
377
378 await SendReply(command, reply, token).ConfigureAwait(false);
379
380 OnExchangeCompleted(new LineQualityExchangeEventArgs(request.SequenceNumber, request.Pattern,
381 request.Data.Length, (reply as EchoResponse)?.Status, _connection.BaudRate));
382 }
383
384 private async Task HandleBaudRateChange(IncomingMessage command, BaudRateChange request,
385 CancellationToken token)
386 {
387 int newBaudRate = 0;
388 var status = BaudRateChangeStatus.UnsupportedRate;
389
390 try
391 {
392 newBaudRate = LineQualityProtocol.ToBaudRate(request.BaudRateId);
393 status = BaudRateChangeStatus.Success;
394 }
395 catch (ArgumentOutOfRangeException)
396 {
397 _logger?.LogWarning("Rejected unsupported baud rate ID {BaudRateId}", request.BaudRateId);
398 }
399
400 int writtenBytes = await SendReply(command,
401 new BaudRateChangeAck(request.SequenceNumber, status), token).ConfigureAwait(false);
402
403 if (status != BaudRateChangeStatus.Success) return;
404
405 // The acknowledgment goes out at the old rate. Retuning before it has physically left
406 // the line would corrupt its tail, so drain first, then observe the settle delay.
407 await _connection.WaitForTransmitCompleteAsync(writtenBytes, token).ConfigureAwait(false);
408 await Task.Delay(BaudRateSettleDelay, token).ConfigureAwait(false);
409
410 _connection.SetBaudRate(newBaudRate);
411 _lastValidCommand = DateTime.UtcNow;
412
413 _logger?.LogInformation("Switched to {BaudRate} baud", newBaudRate);
414 BaudRateChanged?.Invoke(this, new LineQualityBaudRateChangedEventArgs(newBaudRate, false));
415 }
416
417 private async Task<int> SendReply(IncomingMessage command, PayloadData reply, CancellationToken token)
418 {
419 var message = new OutgoingReply(command, reply).BuildMessage(ClearTextChannel.Instance);
420
421 token.ThrowIfCancellationRequested();
422 await _connection.WriteAsync(message).ConfigureAwait(false);
423
424 return message.Length;
425 }
426
427 private void RevertBaudRateIfIdle()
428 {
429 if (AutoRevertTimeout == Timeout.InfiniteTimeSpan) return;
430
431 int fallbackRate = LineQualityProtocol.ToBaudRate(LineQualityBaudRate.Baud9600);
432 if (_connection.BaudRate == fallbackRate) return;
433 if (DateTime.UtcNow - _lastValidCommand < AutoRevertTimeout) return;
434
435 _logger?.LogWarning(
436 "No valid command for {Timeout}; reverting from {Current} to {Fallback} baud",
437 AutoRevertTimeout, _connection.BaudRate, fallbackRate);
438
439 _connection.SetBaudRate(fallbackRate);
440 _lastValidCommand = DateTime.UtcNow;
441
442 BaudRateChanged?.Invoke(this, new LineQualityBaudRateChangedEventArgs(fallbackRate, true));
443 }
444
445 private void OnExchangeCompleted(LineQualityExchangeEventArgs args) =>
446 ExchangeCompleted?.Invoke(this, args);
447 }
448
452 public class LineQualityExchangeEventArgs : EventArgs
453 {
462 public LineQualityExchangeEventArgs(byte sequenceNumber, TestPattern pattern, int payloadLength,
463 EchoStatus? status, int baudRate)
464 {
465 SequenceNumber = sequenceNumber;
466 Pattern = pattern;
467 PayloadLength = payloadLength;
468 Status = status;
469 BaudRate = baudRate;
470 }
471
473 public byte SequenceNumber { get; }
474
476 public TestPattern Pattern { get; }
477
479 public int PayloadLength { get; }
480
482 public EchoStatus? Status { get; }
483
485 public int BaudRate { get; }
486 }
487
491 public class LineQualityBaudRateChangedEventArgs : EventArgs
492 {
499 public LineQualityBaudRateChangedEventArgs(int baudRate, bool wasAutoRevert)
500 {
501 BaudRate = baudRate;
502 WasAutoRevert = wasAutoRevert;
503 }
504
506 public int BaudRate { get; }
507
512 public bool WasAutoRevert { get; }
513 }
514}
LineQualityBaudRateChangedEventArgs(int baudRate, bool wasAutoRevert)
Initializes the event data.
bool WasAutoRevert
Gets a value indicating whether the idle timeout caused the change, rather than the controller reques...
int BaudRate
Gets the rate the responder is now using.
TestPattern Pattern
Gets the requested pattern.
EchoStatus? Status
Gets the status replied with, or null when the reply was a NAK.
int BaudRate
Gets the baud rate the exchange took place at.
LineQualityExchangeEventArgs(byte sequenceNumber, TestPattern pattern, int payloadLength, EchoStatus? status, int baudRate)
Initializes the event data.
byte SequenceNumber
Gets the test sequence number of the request.
int PayloadLength
Gets the number of test data bytes echoed.
Constants and helpers for the OSDP Line Quality Test Procedure.
const byte TestAddress
The dedicated line quality test address, 125 (0x7D).
EventHandler< LineQualityBaudRateChangedEventArgs > BaudRateChanged
Raised whenever the responder retunes, either because the controller asked it to or because the idle ...
async Task RunAsync(CancellationToken token)
Runs the responder until the token is cancelled.
int CurrentBaudRate
Gets the baud rate the responder is currently answering at.
LineQualityResponder(IRetunableOsdpConnection connection, byte address=LineQualityProtocol.TestAddress, ILoggerFactory loggerFactory=null)
Initializes a line quality responder.
TimeSpan BaudRateSettleDelay
Gets or sets how long to wait after a baud rate change acknowledgment has drained before retuning the...
EventHandler< LineQualityExchangeEventArgs > ExchangeCompleted
Raised after each exchange, for progress display and diagnostics.
TimeSpan AutoRevertTimeout
Gets or sets how long the responder waits without a valid command before falling back to 9600 baud.
Represents a message that was received from the wire.
byte Type
Command/reply code of the message.
bool IsDataCorrect
Gets a value indicating whether the data in the incoming message is correct.
byte[] Payload
Returns the raw message payload.
A connection whose line rate can be changed while it stays open.
TestPattern
Test pattern identifiers defined by the OSDP Line Quality Test Procedure, section 3....
EchoStatus
Status codes returned in an Echo Response, per section 3.7 of the Line Quality Test Procedure.
LineQualityBaudRate
Baud Rate identifiers defined by the OSDP Line Quality Test Procedure, section 3.4.
BaudRateChangeStatus
Status codes returned in a Baud Rate Change Acknowledgment, per section 3.7 of the Line Quality Test ...
@ Nak
osdp_NAK - acknowledge; error response
Definition ReplyType.cs:16
CommandType
Identifies the type of OSDP protocol command (ACU -> PD) message.
Definition CommandType.cs:7
ErrorCode
Error code.
Definition Nak.cs:99