OSDP.Net 5.2.1
A .NET library for implementing the Open Supervised Device Protocol (OSDP)
Loading...
Searching...
No Matches
LineQualityTest.cs
1using System;
2using System.Collections.Generic;
3using System.Collections.ObjectModel;
4using System.Diagnostics;
5using System.Linq;
6using System.Threading;
7using System.Threading.Tasks;
8using Microsoft.Extensions.Logging;
11using OSDP.Net.Model;
13
15{
36 public sealed class LineQualityTest
37 {
41 private const int ProbeAttempts = 3;
42
43 private readonly IRetunableOsdpConnection _connection;
44 private readonly ILogger _logger;
45 private readonly Guid _traceId = Guid.NewGuid();
46
47 private LineQualityOptions _options;
48 private byte _controlSequence = 1;
49 private byte _testSequence;
50
58 public LineQualityTest(IRetunableOsdpConnection connection, ILoggerFactory loggerFactory = null)
59 {
60 _connection = connection ?? throw new ArgumentNullException(nameof(connection));
61 _logger = loggerFactory?.CreateLogger<LineQualityTest>();
62 }
63
71 public async Task<LineQualityReport> RunAsync(LineQualityOptions options = null,
72 CancellationToken token = default)
73 {
74 _options = options ?? new LineQualityOptions();
75 var baudRates = (_options.BaudRates ?? LineQualityProtocol.DefaultBaudRates)
76 .Distinct().OrderBy(rate => rate).ToArray();
77
78 if (baudRates.Length == 0)
79 {
80 throw new LineQualityException("No baud rates were selected for testing.");
81 }
82
83 var report = new LineQualityReport(_options.Profile, _connection.ToString(), _options.Address);
84
85 if (!_connection.IsOpen)
86 {
87 await _connection.Open().ConfigureAwait(false);
88 }
89
90 _connection.ReplyTimeout = _options.ResponseTimeout;
91 int baselineRate = LineQualityProtocol.ToBaudRate(LineQualityBaudRate.Baud9600);
92
93 try
94 {
95 await EstablishContact(baselineRate, baudRates, token).ConfigureAwait(false);
96
97 int completed = 0;
98 foreach (int baudRate in baudRates)
99 {
100 token.ThrowIfCancellationRequested();
101
102 var result = report.AddBaudRate(baudRate);
103 var outcome = await SwitchToBaudRate(baudRate, baudRates, token).ConfigureAwait(false);
104
105 if (outcome.Succeeded)
106 {
107 await RunMatrix(result, completed, baudRates.Length, token).ConfigureAwait(false);
108 }
109 else if (outcome.IsResponderLimitation)
110 {
111 // The responder told us it cannot do this rate. That is a property of the
112 // device, not of the cable, so it is not a line quality failure.
113 result.SkipReason = outcome.Reason;
114 _logger?.LogWarning("Skipping {BaudRate} baud: {Reason}", baudRate, outcome.Reason);
115 }
116 else
117 {
118 // The rate change did not complete. Section 6.1 counts that as a failure of
119 // the rate: the line could not carry the transition or could not sustain
120 // the new rate once there.
121 result.FailureReason = outcome.Reason;
122 _logger?.LogWarning("{BaudRate} baud failed: {Reason}", baudRate, outcome.Reason);
123 }
124
125 completed++;
126 }
127 }
128 finally
129 {
130 if (_options.ReturnToBaselineWhenDone && _connection.BaudRate != baselineRate)
131 {
132 await TryReturnToBaseline(baselineRate).ConfigureAwait(false);
133 }
134
135 report.CompletedUtc = DateTime.UtcNow;
136 }
137
138 return report;
139 }
140
144 private async Task EstablishContact(int baselineRate, IReadOnlyList<int> candidateRates,
145 CancellationToken token)
146 {
147 Report("Looking for a responder", _connection.BaudRate, 0, 0, 0, candidateRates.Count);
148
149 if (_connection.BaudRate != baselineRate)
150 {
151 _connection.SetBaudRate(baselineRate);
152 }
153
154 if (await ProbeAsync(token).ConfigureAwait(false)) return;
155
156 _logger?.LogWarning("No responder at {BaudRate} baud; searching other rates", baselineRate);
157
158 if (await RecoverBaudRate(candidateRates, token).ConfigureAwait(false)) return;
159
160 throw new LineQualityException(
161 $"No line quality responder answered at address {_options.Address} on {_connection} " +
162 "at any baud rate. Check wiring, responder power, and that the responder is " +
163 "configured for this address.");
164 }
165
175 private async Task<SwitchOutcome> SwitchToBaudRate(int baudRate,
176 IReadOnlyList<int> candidateRates, CancellationToken token)
177 {
178 if (_connection.BaudRate == baudRate) return SwitchOutcome.Success();
179
180 if (!LineQualityProtocol.TryGetBaudRateId(baudRate, out var baudRateId))
181 {
182 return SwitchOutcome.NotSupported(
183 $"{baudRate} baud has no line quality baud rate ID.");
184 }
185
186 int currentRate = _connection.BaudRate;
187 var status = await RequestBaudRateChange(baudRateId, token).ConfigureAwait(false);
188
189 if (status == BaudRateChangeStatus.UnsupportedRate)
190 {
191 return SwitchOutcome.NotSupported(
192 $"The responder reported that it does not support {baudRate} baud.");
193 }
194
195 if (status == null)
196 {
197 // The responder may still have switched: an acknowledgment that was sent but lost
198 // leaves the two ends on different rates. Search before giving up, so the failure
199 // is recorded against this rate only and the next one starts from a rate that works.
200 await RecoverBaudRate(candidateRates, token).ConfigureAwait(false);
201
202 return SwitchOutcome.Failed(
203 $"The responder did not acknowledge the change to {baudRate} baud, sent at " +
204 $"{currentRate} baud.");
205 }
206
207 if (status != BaudRateChangeStatus.Success)
208 {
209 return SwitchOutcome.Failed(
210 $"The responder reported {status} when asked to switch to {baudRate} baud.");
211 }
212
213 _connection.SetBaudRate(baudRate);
214
215 // Section 4.2 step 6: prove the new rate works before measuring anything at it.
216 if (await ProbeAsync(token).ConfigureAwait(false)) return SwitchOutcome.Success();
217
218 _logger?.LogWarning("No response after switching to {BaudRate} baud; attempting recovery", baudRate);
219 await RecoverBaudRate(candidateRates, token).ConfigureAwait(false);
220
221 return SwitchOutcome.Failed(
222 $"The responder acknowledged the change to {baudRate} baud but could not be " +
223 "reached at that rate, so the line will not sustain it.");
224 }
225
229 private readonly struct SwitchOutcome
230 {
231 private SwitchOutcome(bool succeeded, bool isResponderLimitation, string reason)
232 {
233 Succeeded = succeeded;
234 IsResponderLimitation = isResponderLimitation;
235 Reason = reason;
236 }
237
238 public bool Succeeded { get; }
239
243 public bool IsResponderLimitation { get; }
244
245 public string Reason { get; }
246
247 public static SwitchOutcome Success() => new SwitchOutcome(true, false, null);
248
249 public static SwitchOutcome NotSupported(string reason) =>
250 new SwitchOutcome(false, true, reason);
251
252 public static SwitchOutcome Failed(string reason) =>
253 new SwitchOutcome(false, false, reason);
254 }
255
256 private async Task<BaudRateChangeStatus?> RequestBaudRateChange(LineQualityBaudRate baudRateId,
257 CancellationToken token)
258 {
259 var command = new BaudRateChange(NextTestSequence(), baudRateId);
260 var exchange = await Exchange(command, token).ConfigureAwait(false);
261
262 var reply = exchange.Reply;
263 if (exchange.Outcome != ExchangeOutcome.Received || reply == null) return null;
264 if (!reply.IsDataCorrect || reply.Address != _options.Address) return null;
265 if (reply.Type != (byte)ReplyType.ManufactureSpecific) return null;
266 if (!BaudRateChangeAck.TryParse(reply.Payload.AsSpan(), out var ack)) return null;
267 if (ack.SequenceNumber != command.SequenceNumber) return null;
268
269 if (ack.Status != BaudRateChangeStatus.Success)
270 {
271 _logger?.LogWarning("Responder rejected baud rate {BaudRateId} with status {Status}",
272 baudRateId, ack.Status);
273 return ack.Status;
274 }
275
276 // The acknowledgment went out at the old rate; let it clear the line before either end
277 // retunes, then observe the settle delay both ends agreed on.
278 await _connection.WaitForTransmitCompleteAsync(exchange.CommandLength, token).ConfigureAwait(false);
279 await Task.Delay(_options.BaudRateSettleDelay, token).ConfigureAwait(false);
280
281 return BaudRateChangeStatus.Success;
282 }
283
287 private async Task<bool> RecoverBaudRate(IReadOnlyList<int> candidateRates, CancellationToken token)
288 {
289 var searchOrder = new List<int> { LineQualityProtocol.ToBaudRate(LineQualityBaudRate.Baud9600) };
290 searchOrder.AddRange(candidateRates.OrderBy(rate => rate));
291
292 foreach (int rate in searchOrder.Distinct())
293 {
294 token.ThrowIfCancellationRequested();
295
296 _connection.SetBaudRate(rate);
297 Report($"Recovering: probing {rate} baud", rate, 0, 0, 0, candidateRates.Count);
298
299 // Two attempts per rate. One is too few: a single missed reply during the search
300 // makes the controller walk past the rate the responder is actually on and give up
301 // entirely, which is a far worse outcome than the extra 200 ms per rate costs.
302 if (!await ProbeAsync(token, attempts: 2).ConfigureAwait(false)) continue;
303
304 _logger?.LogInformation("Recovered contact at {BaudRate} baud", rate);
305 return true;
306 }
307
308 // Nothing answered. Leaving the port on the last rate tried — the highest one — would
309 // send every following rate change over a line that has just proven it cannot carry
310 // that rate, turning one real failure into a cascade of invented ones. Fall back to the
311 // baseline, which is also where a responder implementing the idle auto-revert will be.
312 int baselineRate = LineQualityProtocol.ToBaudRate(LineQualityBaudRate.Baud9600);
313 _connection.SetBaudRate(baselineRate);
314
315 _logger?.LogWarning(
316 "No responder found at any rate; returning the controller to {BaudRate} baud so " +
317 "later rates are attempted from a known starting point", baselineRate);
318
319 return false;
320 }
321
332 private async Task TryReturnToBaseline(int baselineRate)
333 {
334 if (!LineQualityProtocol.TryGetBaudRateId(baselineRate, out var baselineId)) return;
335
336 for (int attempt = 0; attempt < ProbeAttempts; attempt++)
337 {
338 try
339 {
340 var status = await RequestBaudRateChange(baselineId, CancellationToken.None)
341 .ConfigureAwait(false);
342
343 if (status == BaudRateChangeStatus.Success)
344 {
345 _connection.SetBaudRate(baselineRate);
346 return;
347 }
348 }
349 catch (Exception exception)
350 {
351 _logger?.LogDebug(exception, "Baud rate reset attempt {Attempt} failed", attempt + 1);
352 }
353 }
354
355 _logger?.LogWarning(
356 "Could not confirm the responder returned to {BaudRate} baud; it may still be at " +
357 "{CurrentRate} until its idle timeout returns it to the baseline",
358 baselineRate, _connection.BaudRate);
359
360 _connection.SetBaudRate(baselineRate);
361 }
362
374 private async Task<bool> ProbeAsync(CancellationToken token, int attempts = ProbeAttempts)
375 {
376 for (int attempt = 0; attempt < attempts; attempt++)
377 {
378 var request = new EchoRequest(NextTestSequence(), TestPattern.AlternatingA, 0);
379 var exchange = await Exchange(request, token).ConfigureAwait(false);
380
381 if (Classify(exchange, request, 0) == ExchangeOutcome.Received) return true;
382 }
383
384 return false;
385 }
386
387 private async Task RunMatrix(BaudRateResult result, int completedRates, int totalRates,
388 CancellationToken token)
389 {
390 int iterations = LineQualityProtocol.IterationsPerCombination(_options.Profile);
391 var combinations = BuildMatrix();
392 int totalPackets = combinations.Count * iterations;
393 int sent = 0;
394
395 foreach (var combination in combinations)
396 {
397 var combinationResult = result.AddCombination(combination.Pattern, combination.PayloadLength);
398
399 for (int iteration = 0; iteration < iterations; iteration++)
400 {
401 token.ThrowIfCancellationRequested();
402
403 var request = new EchoRequest(NextTestSequence(), combination.Pattern,
404 combination.PayloadLength);
405 var exchange = await Exchange(request, token).ConfigureAwait(false);
406 var outcome = Classify(exchange, request, combination.PayloadLength);
407
408 combinationResult.PacketsSent++;
409 switch (outcome)
410 {
411 case ExchangeOutcome.Received:
412 combinationResult.PacketsReceived++;
413 combinationResult.RecordResponseTime(exchange.ResponseTimeMs);
414 break;
415 case ExchangeOutcome.Timeout:
416 combinationResult.Timeouts++;
417 break;
418 case ExchangeOutcome.IntegrityError:
419 combinationResult.IntegrityErrors++;
420 break;
421 case ExchangeOutcome.Nak:
422 combinationResult.Naks++;
423 break;
424 default:
425 combinationResult.PatternMismatches++;
426 break;
427 }
428
429 sent++;
430 if (sent % 10 == 0 || sent == totalPackets)
431 {
432 Report($"{result.BaudRate} baud", result.BaudRate, sent, totalPackets,
433 completedRates, totalRates);
434 }
435
436 // Section 5.7 requires an idle interval of two character times between packets.
437 await Task.Delay(LineQualityProtocol.TransmissionTime(2, _connection.BaudRate), token)
438 .ConfigureAwait(false);
439 }
440 }
441 }
442
448 private static IReadOnlyList<MatrixEntry> BuildMatrix()
449 {
450 var entries = new List<MatrixEntry>();
451
452 foreach (TestPattern pattern in new[]
453 {
454 TestPattern.AllZeros, TestPattern.AllOnes,
455 TestPattern.AlternatingA, TestPattern.Alternating5
456 })
457 {
458 foreach (int length in LineQualityProtocol.TestPayloadLengths)
459 {
460 entries.Add(new MatrixEntry(pattern, length));
461 }
462 }
463
464 foreach (TestPattern pattern in new[] { TestPattern.Sequential, TestPattern.WalkingOne })
465 {
466 foreach (int length in LineQualityProtocol.TestPayloadLengths.Where(length => length > 0))
467 {
468 entries.Add(new MatrixEntry(pattern, length));
469 }
470 }
471
472 return entries;
473 }
474
482 private async Task<ExchangeRecord> Exchange(PayloadData command, CancellationToken token)
483 {
484 var control = new Control(_controlSequence, true, false);
485 var message = new OutgoingMessage(_options.Address, control, command)
486 .BuildMessage(ClearTextChannel.Instance);
487
488 AdvanceControlSequence();
489
490 var stopwatch = Stopwatch.StartNew();
491 await _connection.WriteAsync(message).ConfigureAwait(false);
492 Trace(TraceDirection.Output, message);
493
494 // OSDP section 5.7 measures the reply window from the last character of the command.
495 // A write returns once the bytes are accepted for transmission, not once they have
496 // left the wire, so subtract the frame's own transmission time from what we observe.
497 var commandWireTime = LineQualityProtocol.TransmissionTime(message.Length, _connection.BaudRate);
498 var deadline = commandWireTime + _options.ResponseTimeout;
499
500 var buffer = new Collection<byte>();
501 var firstByteAt = await WaitForReplyStart(buffer, stopwatch, deadline, token).ConfigureAwait(false);
502
503 if (firstByteAt == null)
504 {
505 return ExchangeRecord.TimedOut(message.Length);
506 }
507
508 double responseTimeMs = Math.Max(0.0, firstByteAt.Value.TotalMilliseconds -
509 commandWireTime.TotalMilliseconds);
510
511 if (!await Bus.WaitForMessageLength(_connection, buffer, token).ConfigureAwait(false) ||
512 !await Bus.WaitForRestOfMessage(_connection, buffer, Bus.ExtractMessageLength(buffer), token)
513 .ConfigureAwait(false))
514 {
515 // A frame that started but never finished is a corrupted frame, not a silent line.
516 return ExchangeRecord.Truncated(message.Length, responseTimeMs);
517 }
518
519 var raw = buffer.ToArray();
520 Trace(TraceDirection.Input, raw);
521
522 try
523 {
524 return ExchangeRecord.Replied(message.Length, responseTimeMs,
525 new IncomingMessage(raw, ClearTextChannel.Instance));
526 }
527 catch (Exception exception)
528 {
529 _logger?.LogDebug(exception, "Could not parse a reply frame");
530 return ExchangeRecord.Truncated(message.Length, responseTimeMs);
531 }
532 }
533
537 private async Task<TimeSpan?> WaitForReplyStart(ICollection<byte> buffer, Stopwatch stopwatch,
538 TimeSpan deadline, CancellationToken token)
539 {
540 TimeSpan? firstByteAt = null;
541 var readBuffer = new byte[1];
542
543 while (true)
544 {
545 token.ThrowIfCancellationRequested();
546
547 var remaining = deadline - stopwatch.Elapsed;
548 if (remaining <= TimeSpan.Zero) return null;
549
550 int bytesRead = await ReadWithTimeout(readBuffer, remaining, token).ConfigureAwait(false);
551 if (bytesRead == 0) return null;
552
553 // Time the first byte to reach us, whatever it is. The input buffer was cleared
554 // before the write, so anything arriving now belongs to this reply.
555 firstByteAt = firstByteAt ?? stopwatch.Elapsed;
556
557 if (readBuffer[0] != Message.StartOfMessage) continue;
558
559 buffer.Add(readBuffer[0]);
560 return firstByteAt;
561 }
562 }
563
564 private async Task<int> ReadWithTimeout(byte[] buffer, TimeSpan timeout, CancellationToken token)
565 {
566 using var timeoutSource = new CancellationTokenSource(timeout);
567 using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(token, timeoutSource.Token);
568
569 try
570 {
571 return await _connection.ReadAsync(buffer, linkedSource.Token).ConfigureAwait(false);
572 }
573 catch
574 {
575 token.ThrowIfCancellationRequested();
576 return 0;
577 }
578 }
579
580 private ExchangeOutcome Classify(ExchangeRecord exchange, EchoRequest request, int expectedLength)
581 {
582 if (exchange.Outcome != ExchangeOutcome.Received) return exchange.Outcome;
583
584 var reply = exchange.Reply;
585 if (reply == null || !reply.IsDataCorrect) return ExchangeOutcome.IntegrityError;
586 if (reply.Address != _options.Address) return ExchangeOutcome.PatternMismatch;
587 if (reply.Type == (byte)ReplyType.Nak) return ExchangeOutcome.Nak;
588 if (reply.Type != (byte)ReplyType.ManufactureSpecific) return ExchangeOutcome.PatternMismatch;
589
590 if (!EchoResponse.TryParse(reply.Payload.AsSpan(), out var response))
591 {
592 return ExchangeOutcome.PatternMismatch;
593 }
594
595 if (response.SequenceNumber != request.SequenceNumber) return ExchangeOutcome.PatternMismatch;
596 if (response.Status != EchoStatus.Success) return ExchangeOutcome.PatternMismatch;
597 if (response.Data.Length != expectedLength) return ExchangeOutcome.PatternMismatch;
598
599 return LineQualityProtocol.ValidatePattern(request.Pattern, response.Data)
600 ? ExchangeOutcome.Received
601 : ExchangeOutcome.PatternMismatch;
602 }
603
604 private void AdvanceControlSequence()
605 {
606 // OSDP section 5.9: sequence numbers cycle 1, 2, 3 and skip zero, which is reserved
607 // for signalling that communications have been reset. The sequence advances even after
608 // a timeout: holding it would ask the responder to replay its cached reply, and that
609 // replay would be counted as a successful delivery.
610 _controlSequence = (byte)(_controlSequence >= 3 ? 1 : _controlSequence + 1);
611 }
612
613 private byte NextTestSequence()
614 {
615 byte current = _testSequence;
616 _testSequence = (byte)((_testSequence + 1) & 0xFF);
617 return current;
618 }
619
620 private void Trace(TraceDirection direction, byte[] data)
621 {
622 var tracer = _options?.Tracer;
623 if (tracer == null) return;
624
625 // The outgoing buffer carries a leading driver byte that is not part of the packet.
626 var packet = direction == TraceDirection.Output ? data.Skip(1).ToArray() : data;
627 tracer(new TraceEntry(direction, _traceId, packet));
628 }
629
630 private void Report(string message, int baudRate, int sent, int total, int completedRates,
631 int totalRates)
632 {
633 _options?.Progress?.Report(new LineQualityProgress(message, baudRate, sent, total,
634 completedRates, totalRates));
635 }
636
637 private readonly struct MatrixEntry
638 {
639 public MatrixEntry(TestPattern pattern, int payloadLength)
640 {
641 Pattern = pattern;
642 PayloadLength = payloadLength;
643 }
644
645 public TestPattern Pattern { get; }
646
647 public int PayloadLength { get; }
648 }
649
650 private enum ExchangeOutcome
651 {
652 Received,
653 Timeout,
654 IntegrityError,
655 Nak,
656 PatternMismatch
657 }
658
659 private readonly struct ExchangeRecord
660 {
661 private ExchangeRecord(ExchangeOutcome outcome, int commandLength, double responseTimeMs,
662 IncomingMessage reply)
663 {
664 Outcome = outcome;
665 CommandLength = commandLength;
666 ResponseTimeMs = responseTimeMs;
667 Reply = reply;
668 }
669
670 public ExchangeOutcome Outcome { get; }
671
672 public int CommandLength { get; }
673
674 public double ResponseTimeMs { get; }
675
676 public IncomingMessage Reply { get; }
677
678 public static ExchangeRecord TimedOut(int commandLength) =>
679 new ExchangeRecord(ExchangeOutcome.Timeout, commandLength, 0.0, null);
680
681 public static ExchangeRecord Truncated(int commandLength, double responseTimeMs) =>
682 new ExchangeRecord(ExchangeOutcome.IntegrityError, commandLength, responseTimeMs, null);
683
684 public static ExchangeRecord Replied(int commandLength, double responseTimeMs,
685 IncomingMessage reply) =>
686 new ExchangeRecord(ExchangeOutcome.Received, commandLength, responseTimeMs, reply);
687 }
688
689 }
690
694 public class LineQualityException : OSDPNetException
695 {
700 public LineQualityException(string message) : base(message)
701 {
702 }
703 }
704}
Raised when a line quality test cannot be run.
LineQualityException(string message)
Initializes the exception.
Options controlling a line quality test run.
Constants and helpers for the OSDP Line Quality Test Procedure.
static int ToBaudRate(LineQualityBaudRate baudRateId)
Converts a Baud Rate ID into the baud rate it represents.
static IReadOnlyList< int > DefaultBaudRates
The six baud rates named by OSDP section 5.2, in ascending order.
The complete results of a line quality test run.
LineQualityTest(IRetunableOsdpConnection connection, ILoggerFactory loggerFactory=null)
Initializes a line quality test controller.
async Task< LineQualityReport > RunAsync(LineQualityOptions options=null, CancellationToken token=default)
Runs a line quality test.
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 ...
ReplyType
Identifies the type of OSDP protocol reply (PD -> ACU) message.
Definition ReplyType.cs:7
TraceDirection
Direction of trace.
Definition TraceEntry.cs:9