41 private const int ProbeAttempts = 3;
44 private readonly ILogger _logger;
45 private readonly Guid _traceId = Guid.NewGuid();
48 private byte _controlSequence = 1;
49 private byte _testSequence;
60 _connection = connection ??
throw new ArgumentNullException(nameof(connection));
72 CancellationToken token =
default)
76 .Distinct().OrderBy(rate => rate).ToArray();
78 if (baudRates.Length == 0)
83 var report =
new LineQualityReport(_options.Profile, _connection.ToString(), _options.Address);
85 if (!_connection.IsOpen)
87 await _connection.Open().ConfigureAwait(
false);
90 _connection.ReplyTimeout = _options.ResponseTimeout;
95 await EstablishContact(baselineRate, baudRates, token).ConfigureAwait(
false);
98 foreach (
int baudRate
in baudRates)
100 token.ThrowIfCancellationRequested();
102 var result = report.AddBaudRate(baudRate);
103 var outcome = await SwitchToBaudRate(baudRate, baudRates, token).ConfigureAwait(
false);
105 if (outcome.Succeeded)
107 await RunMatrix(result, completed, baudRates.Length, token).ConfigureAwait(
false);
109 else if (outcome.IsResponderLimitation)
113 result.SkipReason = outcome.Reason;
114 _logger?.LogWarning(
"Skipping {BaudRate} baud: {Reason}", baudRate, outcome.Reason);
121 result.FailureReason = outcome.Reason;
122 _logger?.LogWarning(
"{BaudRate} baud failed: {Reason}", baudRate, outcome.Reason);
130 if (_options.ReturnToBaselineWhenDone && _connection.BaudRate != baselineRate)
132 await TryReturnToBaseline(baselineRate).ConfigureAwait(
false);
135 report.CompletedUtc = DateTime.UtcNow;
144 private async Task EstablishContact(
int baselineRate, IReadOnlyList<int> candidateRates,
145 CancellationToken token)
147 Report(
"Looking for a responder", _connection.BaudRate, 0, 0, 0, candidateRates.Count);
149 if (_connection.BaudRate != baselineRate)
151 _connection.SetBaudRate(baselineRate);
154 if (await ProbeAsync(token).ConfigureAwait(
false))
return;
156 _logger?.LogWarning(
"No responder at {BaudRate} baud; searching other rates", baselineRate);
158 if (await RecoverBaudRate(candidateRates, token).ConfigureAwait(
false))
return;
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.");
175 private async Task<SwitchOutcome> SwitchToBaudRate(
int baudRate,
176 IReadOnlyList<int> candidateRates, CancellationToken token)
178 if (_connection.BaudRate == baudRate)
return SwitchOutcome.Success();
180 if (!LineQualityProtocol.TryGetBaudRateId(baudRate, out var baudRateId))
182 return SwitchOutcome.NotSupported(
183 $
"{baudRate} baud has no line quality baud rate ID.");
186 int currentRate = _connection.BaudRate;
187 var status = await RequestBaudRateChange(baudRateId, token).ConfigureAwait(
false);
191 return SwitchOutcome.NotSupported(
192 $
"The responder reported that it does not support {baudRate} baud.");
200 await RecoverBaudRate(candidateRates, token).ConfigureAwait(
false);
202 return SwitchOutcome.Failed(
203 $
"The responder did not acknowledge the change to {baudRate} baud, sent at " +
204 $
"{currentRate} baud.");
209 return SwitchOutcome.Failed(
210 $
"The responder reported {status} when asked to switch to {baudRate} baud.");
213 _connection.SetBaudRate(baudRate);
216 if (await ProbeAsync(token).ConfigureAwait(
false))
return SwitchOutcome.Success();
218 _logger?.LogWarning(
"No response after switching to {BaudRate} baud; attempting recovery", baudRate);
219 await RecoverBaudRate(candidateRates, token).ConfigureAwait(
false);
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.");
229 private readonly
struct SwitchOutcome
231 private SwitchOutcome(
bool succeeded,
bool isResponderLimitation,
string reason)
233 Succeeded = succeeded;
234 IsResponderLimitation = isResponderLimitation;
238 public bool Succeeded {
get; }
243 public bool IsResponderLimitation {
get; }
245 public string Reason {
get; }
247 public static SwitchOutcome Success() =>
new SwitchOutcome(
true,
false,
null);
249 public static SwitchOutcome NotSupported(
string reason) =>
250 new SwitchOutcome(
false,
true, reason);
252 public static SwitchOutcome Failed(
string reason) =>
253 new SwitchOutcome(
false,
false, reason);
256 private async Task<BaudRateChangeStatus?> RequestBaudRateChange(
LineQualityBaudRate baudRateId,
257 CancellationToken token)
259 var command =
new BaudRateChange(NextTestSequence(), baudRateId);
260 var exchange = await Exchange(command, token).ConfigureAwait(
false);
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;
271 _logger?.LogWarning(
"Responder rejected baud rate {BaudRateId} with status {Status}",
272 baudRateId, ack.Status);
278 await _connection.WaitForTransmitCompleteAsync(exchange.CommandLength, token).ConfigureAwait(
false);
279 await Task.Delay(_options.BaudRateSettleDelay, token).ConfigureAwait(
false);
287 private async Task<bool> RecoverBaudRate(IReadOnlyList<int> candidateRates, CancellationToken token)
289 var searchOrder =
new List<int> { LineQualityProtocol.ToBaudRate(
LineQualityBaudRate.Baud9600) };
290 searchOrder.AddRange(candidateRates.OrderBy(rate => rate));
292 foreach (
int rate
in searchOrder.Distinct())
294 token.ThrowIfCancellationRequested();
296 _connection.SetBaudRate(rate);
297 Report($
"Recovering: probing {rate} baud", rate, 0, 0, 0, candidateRates.Count);
302 if (!await ProbeAsync(token, attempts: 2).ConfigureAwait(
false))
continue;
304 _logger?.LogInformation(
"Recovered contact at {BaudRate} baud", rate);
313 _connection.SetBaudRate(baselineRate);
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);
332 private async Task TryReturnToBaseline(
int baselineRate)
334 if (!LineQualityProtocol.TryGetBaudRateId(baselineRate, out var baselineId))
return;
336 for (
int attempt = 0; attempt < ProbeAttempts; attempt++)
340 var status = await RequestBaudRateChange(baselineId, CancellationToken.None)
341 .ConfigureAwait(
false);
345 _connection.SetBaudRate(baselineRate);
349 catch (Exception exception)
351 _logger?.LogDebug(exception,
"Baud rate reset attempt {Attempt} failed", attempt + 1);
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);
360 _connection.SetBaudRate(baselineRate);
374 private async Task<bool> ProbeAsync(CancellationToken token,
int attempts = ProbeAttempts)
376 for (
int attempt = 0; attempt < attempts; attempt++)
378 var request =
new EchoRequest(NextTestSequence(),
TestPattern.AlternatingA, 0);
379 var exchange = await Exchange(request, token).ConfigureAwait(
false);
381 if (Classify(exchange, request, 0) == ExchangeOutcome.Received)
return true;
387 private async Task RunMatrix(BaudRateResult result,
int completedRates,
int totalRates,
388 CancellationToken token)
390 int iterations = LineQualityProtocol.IterationsPerCombination(_options.Profile);
391 var combinations = BuildMatrix();
392 int totalPackets = combinations.Count * iterations;
395 foreach (var combination
in combinations)
397 var combinationResult = result.AddCombination(combination.Pattern, combination.PayloadLength);
399 for (
int iteration = 0; iteration < iterations; iteration++)
401 token.ThrowIfCancellationRequested();
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);
408 combinationResult.PacketsSent++;
411 case ExchangeOutcome.Received:
412 combinationResult.PacketsReceived++;
413 combinationResult.RecordResponseTime(exchange.ResponseTimeMs);
415 case ExchangeOutcome.Timeout:
416 combinationResult.Timeouts++;
418 case ExchangeOutcome.IntegrityError:
419 combinationResult.IntegrityErrors++;
421 case ExchangeOutcome.Nak:
422 combinationResult.Naks++;
425 combinationResult.PatternMismatches++;
430 if (sent % 10 == 0 || sent == totalPackets)
432 Report($
"{result.BaudRate} baud", result.BaudRate, sent, totalPackets,
433 completedRates, totalRates);
437 await Task.Delay(LineQualityProtocol.TransmissionTime(2, _connection.BaudRate), token)
438 .ConfigureAwait(
false);
448 private static IReadOnlyList<MatrixEntry> BuildMatrix()
450 var entries =
new List<MatrixEntry>();
458 foreach (
int length
in LineQualityProtocol.TestPayloadLengths)
460 entries.Add(
new MatrixEntry(pattern, length));
466 foreach (
int length
in LineQualityProtocol.TestPayloadLengths.Where(length => length > 0))
468 entries.Add(
new MatrixEntry(pattern, length));
482 private async Task<ExchangeRecord> Exchange(PayloadData command, CancellationToken token)
484 var control =
new Control(_controlSequence,
true,
false);
485 var message =
new OutgoingMessage(_options.Address, control, command)
486 .BuildMessage(ClearTextChannel.Instance);
488 AdvanceControlSequence();
490 var stopwatch = Stopwatch.StartNew();
491 await _connection.WriteAsync(message).ConfigureAwait(
false);
497 var commandWireTime = LineQualityProtocol.TransmissionTime(message.Length, _connection.BaudRate);
498 var deadline = commandWireTime + _options.ResponseTimeout;
500 var buffer =
new Collection<byte>();
501 var firstByteAt = await WaitForReplyStart(buffer, stopwatch, deadline, token).ConfigureAwait(
false);
503 if (firstByteAt ==
null)
505 return ExchangeRecord.TimedOut(message.Length);
508 double responseTimeMs = Math.Max(0.0, firstByteAt.Value.TotalMilliseconds -
509 commandWireTime.TotalMilliseconds);
511 if (!await Bus.WaitForMessageLength(_connection, buffer, token).ConfigureAwait(
false) ||
512 !await Bus.WaitForRestOfMessage(_connection, buffer, Bus.ExtractMessageLength(buffer), token)
513 .ConfigureAwait(
false))
516 return ExchangeRecord.Truncated(message.Length, responseTimeMs);
519 var raw = buffer.ToArray();
524 return ExchangeRecord.Replied(message.Length, responseTimeMs,
525 new IncomingMessage(raw, ClearTextChannel.Instance));
527 catch (Exception exception)
529 _logger?.LogDebug(exception,
"Could not parse a reply frame");
530 return ExchangeRecord.Truncated(message.Length, responseTimeMs);
537 private async Task<TimeSpan?> WaitForReplyStart(ICollection<byte> buffer, Stopwatch stopwatch,
538 TimeSpan deadline, CancellationToken token)
540 TimeSpan? firstByteAt =
null;
541 var readBuffer =
new byte[1];
545 token.ThrowIfCancellationRequested();
547 var remaining = deadline - stopwatch.Elapsed;
548 if (remaining <= TimeSpan.Zero)
return null;
550 int bytesRead = await ReadWithTimeout(readBuffer, remaining, token).ConfigureAwait(
false);
551 if (bytesRead == 0)
return null;
555 firstByteAt = firstByteAt ?? stopwatch.Elapsed;
557 if (readBuffer[0] != Message.StartOfMessage)
continue;
559 buffer.Add(readBuffer[0]);
564 private async Task<int> ReadWithTimeout(
byte[] buffer, TimeSpan timeout, CancellationToken token)
566 using var timeoutSource =
new CancellationTokenSource(timeout);
567 using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(token, timeoutSource.Token);
571 return await _connection.ReadAsync(buffer, linkedSource.Token).ConfigureAwait(
false);
575 token.ThrowIfCancellationRequested();
580 private ExchangeOutcome Classify(ExchangeRecord exchange, EchoRequest request,
int expectedLength)
582 if (exchange.Outcome != ExchangeOutcome.Received)
return exchange.Outcome;
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;
590 if (!EchoResponse.TryParse(reply.Payload.AsSpan(), out var response))
592 return ExchangeOutcome.PatternMismatch;
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;
599 return LineQualityProtocol.ValidatePattern(request.Pattern, response.Data)
600 ? ExchangeOutcome.Received
601 : ExchangeOutcome.PatternMismatch;
604 private void AdvanceControlSequence()
610 _controlSequence = (byte)(_controlSequence >= 3 ? 1 : _controlSequence + 1);
613 private byte NextTestSequence()
615 byte current = _testSequence;
616 _testSequence = (byte)((_testSequence + 1) & 0xFF);
622 var tracer = _options?.Tracer;
623 if (tracer ==
null)
return;
626 var packet = direction == TraceDirection.Output ? data.Skip(1).ToArray() : data;
627 tracer(
new TraceEntry(direction, _traceId, packet));
630 private void Report(
string message,
int baudRate,
int sent,
int total,
int completedRates,
633 _options?.Progress?.Report(
new LineQualityProgress(message, baudRate, sent, total,
634 completedRates, totalRates));
637 private readonly
struct MatrixEntry
639 public MatrixEntry(
TestPattern pattern,
int payloadLength)
642 PayloadLength = payloadLength;
647 public int PayloadLength {
get; }
650 private enum ExchangeOutcome
659 private readonly
struct ExchangeRecord
661 private ExchangeRecord(ExchangeOutcome outcome,
int commandLength,
double responseTimeMs,
662 IncomingMessage reply)
665 CommandLength = commandLength;
666 ResponseTimeMs = responseTimeMs;
670 public ExchangeOutcome Outcome {
get; }
672 public int CommandLength {
get; }
674 public double ResponseTimeMs {
get; }
676 public IncomingMessage Reply {
get; }
678 public static ExchangeRecord TimedOut(
int commandLength) =>
679 new ExchangeRecord(ExchangeOutcome.Timeout, commandLength, 0.0,
null);
681 public static ExchangeRecord Truncated(
int commandLength,
double responseTimeMs) =>
682 new ExchangeRecord(ExchangeOutcome.IntegrityError, commandLength, responseTimeMs,
null);
684 public static ExchangeRecord Replied(
int commandLength,
double responseTimeMs,
685 IncomingMessage reply) =>
686 new ExchangeRecord(ExchangeOutcome.Received, commandLength, responseTimeMs, reply);