From c71fe7544681f9a021eb86c4509871ed62411231 Mon Sep 17 00:00:00 2001 From: Michal Buzik Date: Tue, 18 Aug 2026 10:01:02 +0200 Subject: [PATCH] Ally iPerl initial - reader,test method, unit test Add AllyReader and AllyCalibration support with tests: Introduce new register readers, calibration methods, and comprehensive unit tests for integration and functionality validation. Update project files accordingly. --- .../AllyReader/AllyMeterReader.cs | 618 ++++++++++++++++++ .../AllyReader/AllyMeterSize.cs | 11 + .../AllyReader/AllyOpticalSample.cs | 81 +++ .../AllyReader/AllyReaderCfg.cs | 191 ++++++ .../Communication/AllyCommandService.cs | 208 ++++++ .../AllyReader/Communication/AllyFrame.cs | 31 + .../Communication/AllyFrameBuilder.cs | 85 +++ .../Communication/AllyFrameParser.cs | 29 + .../AllyReader/Communication/AllyProtocol.cs | 62 ++ .../AllyReader/Communication/AllyResponse.cs | 51 ++ .../Communication/AllySerialTransport.cs | 178 +++++ .../Communication/AllyVersionInfo.cs | 45 ++ .../Communication/IAllyTransport.cs | 11 + TBF/Rig/RegisterReaders/AllyReader/Factory.cs | 35 + .../AllyCalibrationActivity.cs | 86 +++ .../AllyCalibration/AllyCalibrationSeq.cs | 259 ++++++++ .../TestMethods/AllyCalibration/Factory.cs | 30 + .../TestMethods/AllyCalibration/TestMethod.cs | 94 +++ .../AllyCalibration/TestMethodCfg.cs | 186 ++++++ .../AllyCalibration/TestMethodParams.cs | 163 +++++ TBF/TBF.csproj | 22 +- .../AllyReader/AllyMeterReaderTest.cs | 125 ++++ .../AllyReader/AllyOpticalSampleTest.cs | 70 ++ .../AllyReader/AllyOpticalTelegramFactory.cs | 27 + .../AllyReader/AllyReaderCfgTest.cs | 87 +++ .../communication/AllyCommandServiceTest.cs | 179 +++++ .../communication/AllyFrameParserTest.cs | 56 ++ .../AllyReader/communication/AllyFrameTest.cs | 38 ++ .../communication/AllyResponseFactory.cs | 45 ++ .../AllySerialTransportBuilderTest.cs | 41 ++ .../communication/AllyVersionInfoTest.cs | 37 ++ .../communication/FakeAllyTransport.cs | 35 + .../AllyHardwareIntegrationSettings.cs | 97 +++ .../AllyHardwareIntegrationTest.cs | 331 ++++++++++ .../integration/AllyIntegrationTestLogger.cs | 92 +++ .../AllyCalibration/TestMethodConfigTest.cs | 42 ++ TBFTests/TBFTests.csproj | 18 +- 37 files changed, 3794 insertions(+), 2 deletions(-) create mode 100644 TBF/Rig/RegisterReaders/AllyReader/AllyMeterReader.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/AllyMeterSize.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/AllyOpticalSample.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/AllyReaderCfg.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/Communication/AllyCommandService.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/Communication/AllyFrame.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/Communication/AllyFrameBuilder.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/Communication/AllyFrameParser.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/Communication/AllyProtocol.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/Communication/AllyResponse.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/Communication/AllySerialTransport.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/Communication/AllyVersionInfo.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/Communication/IAllyTransport.cs create mode 100644 TBF/Rig/RegisterReaders/AllyReader/Factory.cs create mode 100644 TBF/Rig/TestMethods/AllyCalibration/AllyCalibrationActivity.cs create mode 100644 TBF/Rig/TestMethods/AllyCalibration/AllyCalibrationSeq.cs create mode 100644 TBF/Rig/TestMethods/AllyCalibration/Factory.cs create mode 100644 TBF/Rig/TestMethods/AllyCalibration/TestMethod.cs create mode 100644 TBF/Rig/TestMethods/AllyCalibration/TestMethodCfg.cs create mode 100644 TBF/Rig/TestMethods/AllyCalibration/TestMethodParams.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/AllyMeterReaderTest.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/AllyOpticalSampleTest.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/AllyOpticalTelegramFactory.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/AllyReaderCfgTest.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyCommandServiceTest.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyFrameParserTest.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyFrameTest.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyResponseFactory.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/communication/AllySerialTransportBuilderTest.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyVersionInfoTest.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/communication/FakeAllyTransport.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/integration/AllyHardwareIntegrationSettings.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/integration/AllyHardwareIntegrationTest.cs create mode 100644 TBFTests/Rig/RegisterReaders/AllyReader/integration/AllyIntegrationTestLogger.cs create mode 100644 TBFTests/Rig/TestMethods/AllyCalibration/TestMethodConfigTest.cs diff --git a/TBF/Rig/RegisterReaders/AllyReader/AllyMeterReader.cs b/TBF/Rig/RegisterReaders/AllyReader/AllyMeterReader.cs new file mode 100644 index 000000000..43cb37b7a --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/AllyMeterReader.cs @@ -0,0 +1,618 @@ +using System; +using System.Collections.Generic; +using System.IO.Ports; +using System.Text; +using Common; +using Config.Entities; +using log4net; +using TBF.Rig.Generic; +using TBF.Rig.GenericDevices; +using TBF.Rig.RegisterReaders.AllyReader.Communication; + +namespace TBF.Rig.RegisterReaders.AllyReader +{ + public class AllyMeterReader : ComponentBase, + IDevice, + IRegReaderDatastream, + ISessionDataMngmnt, + IOperation + { + private const int MaxStoredSamples = 40000; + private const long RawVolumeModulo = 0x1000000L; + private const long RawVolumeHalfRange = RawVolumeModulo / 2; + private const long RawTimestampModulo = 0x100000000L; + private const long RawTimestampHalfRange = RawTimestampModulo / 2; + private static readonly ILog log = LogManager.GetLogger(typeof(AllyMeterReader)); + + private readonly object commandSync = new object(); + private readonly object opticalSync = new object(); + private readonly StringBuilder opticalBuffer = new StringBuilder(); + private readonly List opticalSamples = new List(); + + private readonly AllyReaderCfg allyCfg; + private IAllyTransport commandTransport; + private AllyCommandService commandService; + private SerialPort opticalPort; + private bool streamEnabled; + private bool operationActive; + private bool hasPreviousRawVolume; + private bool hasPreviousRawTimestamp; + private bool hasTestStartSample; + private uint previousRawVolume; + private uint previousRawTimestamp; + private long extendedRawVolume; + private long extendedRawTimestamp; + private double beginWMState; + private double endWMState; + private double timestampSecStart; + private double timestampSecEnd; + private string lastOpticalLine; + + public AllyMeterReader() + { + } + + public AllyMeterReader(IComponentCfg cfg) + : base(cfg) + { + allyCfg = cfg as AllyReaderCfg; + } + + public override string ToString() + { + return string.Format("{0}({1})", ClassName, Cfg.ToString(-1)); + } + + public string SerialNumber { get; private set; } + public AllyVersionInfo VersionInfo { get; private set; } + public DateTime? SystemTimeUtc { get; private set; } + public byte? RebootCount { get; private set; } + public double? CalibrationFactorPercent { get; private set; } + public double? ExpectedCalibrationFactorPercent { get; private set; } + public bool CommFailed { get; private set; } + + public AllyMeterSize ConfiguredMeterSize + { + get { return allyCfg == null ? AllyMeterSize.AutoDetect : allyCfg.ConfiguredMeterSize; } + } + + public IReadOnlyList OpticalSamples + { + get + { + lock (opticalSync) + { + return opticalSamples.ToArray(); + } + } + } + + public override void Initialize() + { + if (allyCfg == null) + throw new InvalidOperationException("ALLY reader configuration is missing."); + + if (allyCfg.CommandComPortNr <= 0 || allyCfg.OptoComPortNr <= 0) + throw new InvalidOperationException("ALLY COM port configuration is invalid."); + + PulsesPerLtr = allyCfg.MeterPulsesPerLiter; + QuantityUnits = "l"; + log.FatalFormat("{0} initialized: {1}", Name, this); + } + + public void RunDeviceBefore() + { + if (!streamEnabled || opticalPort == null || !opticalPort.IsOpen) + return; + + try + { + string text = opticalPort.ReadExisting(); + if (!string.IsNullOrEmpty(text)) + ProcessOpticalText(text); + } + catch (Exception ex) + { + CommFailed = true; + log.Error("ALLY optical stream read failed.", ex); + } + } + + public void RunDeviceAfter() + { + } + + public void StopDevice() + { + StopDataStreamProcessing(); + CloseCommandTransport(); + } + + public void StopDevice2() + { + } + + public void StartSession() + { + lock (opticalSync) + { + operationActive = false; + opticalSamples.Clear(); + opticalBuffer.Clear(); + lastOpticalLine = string.Empty; + ResetVolumeState(); + } + + SerialNumber = string.Empty; + VersionInfo = null; + SystemTimeUtc = null; + RebootCount = null; + CalibrationFactorPercent = null; + ExpectedCalibrationFactorPercent = null; + CommFailed = false; + } + + public void SaveMark(object mark) + { + } + + public void EndSession() + { + StopDataStreamProcessing(); + CloseCommandTransport(); + } + + public void TestIsGoingToStartSoon(Test test, int repetitionNr) + { + } + + public IOperation ReadDatastreamOp() + { + return this; + } + + public void Start() + { + lock (opticalSync) + { + operationActive = true; + hasTestStartSample = false; + beginWMState = 0; + endWMState = 0; + timestampSecStart = 0; + timestampSecEnd = 0; + } + + StartDataStreamProcessing(); + } + + public Event Run() + { + return Event.ReadRegisterDone; + } + + public void Stop() + { + lock (opticalSync) + { + operationActive = false; + } + StopDataStreamProcessing(); + } + + public void StartDataStreamProcessing() + { + GetOpticalVolumeLitersPerRawUnit(); + + lock (opticalSync) + { + if (streamEnabled) + return; + + opticalSamples.Clear(); + opticalBuffer.Clear(); + lastOpticalLine = string.Empty; + ResetVolumeState(); + if (DebugLevel == DebugMode.Normal) + { + opticalPort = new SerialPort( + "COM" + allyCfg.OptoComPortNr, + allyCfg.OptoBaudRate, + Parity.None, + 8, + StopBits.One); + opticalPort.Open(); + opticalPort.DiscardInBuffer(); + } + + streamEnabled = true; + } + } + + public void StopDataStreamProcessing() + { + lock (opticalSync) + { + streamEnabled = false; + if (opticalPort == null) + return; + + try + { + if (opticalPort.IsOpen) + opticalPort.Close(); + } + finally + { + opticalPort.Dispose(); + opticalPort = null; + } + } + } + + public string ReadOptoData() + { + lock (opticalSync) + { + return lastOpticalLine ?? string.Empty; + } + } + + public string ReadSerialNumber(int timeoutMs) + { + if (DebugLevel != DebugMode.Normal) + return SerialNumber = "ALLY-SIMULATED"; + + return ExecuteCommand(service => SerialNumber = service.ReadSerialNumber(timeoutMs)); + } + + public AllyVersionInfo ReadVersionAndType(int timeoutMs) + { + if (DebugLevel != DebugMode.Normal) + return VersionInfo = AllyVersionInfo.Parse("SIMULATED,SWM003,SIMULATED"); + + return ExecuteCommand(service => VersionInfo = service.ReadVersionAndType(timeoutMs)); + } + + public DateTime ReadSystemTimeUtc(int timeoutMs) + { + if (DebugLevel != DebugMode.Normal) + return (SystemTimeUtc = DateTime.UtcNow).Value; + + return ExecuteCommand(service => (SystemTimeUtc = service.ReadSystemTimeUtc(timeoutMs)).Value); + } + + public byte ReadRebootCount(int timeoutMs) + { + if (DebugLevel != DebugMode.Normal) + return (RebootCount = 0).Value; + + return ExecuteCommand(service => (RebootCount = service.ReadRebootCount(timeoutMs)).Value); + } + + public void OpenValve(int timeoutMs) + { + ExecuteCommand(service => service.OpenValve(timeoutMs)); + } + + public void SetSpreadSpectrum(bool enabled, int timeoutMs) + { + ExecuteCommand(service => service.SetSpreadSpectrum(enabled, timeoutMs)); + } + + public void SetMeterMode(byte mode, int timeoutMs) + { + ExecuteCommand(service => service.SetMeterMode(mode, timeoutMs)); + } + + public void SetDiagnosticLed(byte mode, int timeoutMs) + { + ExecuteCommand(service => service.SetDiagnosticLed(mode, timeoutMs)); + } + + public double ResetCalibrationFactor(int timeoutMs) + { + double factor = GetResetCalibrationFactorPercent(); + ExecuteCommand(service => service.SetCalibrationFactorPercent(factor, timeoutMs)); + ExpectedCalibrationFactorPercent = factor; + return factor; + } + + public double ReadCalibrationFactor(int timeoutMs) + { + if (DebugLevel != DebugMode.Normal) + { + double simulated = ExpectedCalibrationFactorPercent ?? GetResetCalibrationFactorPercent(); + CalibrationFactorPercent = simulated; + return simulated; + } + + return ExecuteCommand( + service => (CalibrationFactorPercent = + service.ReadCalibrationFactorPercent(timeoutMs)).Value); + } + + public void SetCalibrationFactor(double factorPercent, int timeoutMs) + { + if (!IsCalibrationFactorWithinSpecification(factorPercent)) + { + throw new ArgumentOutOfRangeException( + nameof(factorPercent), + "ALLY calibration factor is outside the UI-2093 limit for the configured meter size."); + } + + ExecuteCommand(service => service.SetCalibrationFactorPercent(factorPercent, timeoutMs)); + ExpectedCalibrationFactorPercent = factorPercent; + } + + public void SetMagneticTamperProfile(AllyMagneticTamperProfile profile, int timeoutMs) + { + ExecuteCommand(service => service.SetMagneticTamperProfile(profile, timeoutMs)); + } + + public void SetDisplayVolume(string eightDigitVolume, int timeoutMs) + { + ExecuteCommand(service => service.SetDisplayVolume(eightDigitVolume, timeoutMs)); + } + + public void SetLcdTimeout(byte seconds, int timeoutMs) + { + ExecuteCommand(service => service.SetLcdTimeout(seconds, timeoutMs)); + } + + public void StartOffsetLearning(ushort samples, ushort delaySeconds, int timeoutMs) + { + ExecuteCommand(service => service.StartOffsetLearning(samples, delaySeconds, timeoutMs)); + } + + public double GetResetCalibrationFactorPercent() + { + switch (ConfiguredMeterSize) + { + case AllyMeterSize.FiveEighths: + case AllyMeterSize.ThreeQuarterShort: + case AllyMeterSize.ThreeQuarterLong: + return 100D; + case AllyMeterSize.OneInch: + return 260D; + default: + throw new InvalidOperationException( + "ALLY meter size is AutoDetect. UI-2031 serial-number parsing is required before selecting the reset calibration factor."); + } + } + + public bool IsCalibrationFactorWithinSpecification(double value) + { + switch (ConfiguredMeterSize) + { + case AllyMeterSize.FiveEighths: return value >= 76D && value <= 96D; + case AllyMeterSize.ThreeQuarterShort: return value >= 88D && value <= 108D; + case AllyMeterSize.ThreeQuarterLong: return value >= 89D && value <= 120D; + case AllyMeterSize.OneInch: return value >= 236D && value <= 281D; + default: + throw new InvalidOperationException( + "ALLY meter size is AutoDetect. Calibration-factor limits cannot be selected safely."); + } + } + + public RegisterReaderType RegisterReaderType { get { return RegisterReaderType.DataStream; } } + + public int Position + { + get + { + if (string.IsNullOrEmpty(Name)) + return 0; + + int start = Name.Length; + while (start > 0 && char.IsDigit(Name[start - 1])) + start--; + + int value; + return start < Name.Length && int.TryParse(Name.Substring(start), out value) ? value : 0; + } + } + + public double PulsesPerLtr { get; set; } + public double LtrsPerPulse { get { return PulsesPerLtr > 0 ? 1D / PulsesPerLtr : 0D; } } + public string QuantityUnits { get; set; } + public int WMPulses { get { return (int)Math.Round(WMVolume * PulsesPerLtr); } } + public int WMRefPulses + { + get + { + return StateMachine.ControlBoardMain == null + ? 0 + : StateMachine.ControlBoardMain.RefPulses; + } + } + public double WMVolume { get { return Math.Abs(EndWMState - BeginWMState); } } + public double BeginWMState { get { return beginWMState; } } + public double EndWMState { get { return endWMState; } } + public bool NoSamples { get { return !hasTestStartSample || opticalSamples.Count < 2; } } + public double VolumeLtrStart { get { return beginWMState; } } + public double VolumeLtrEnd { get { return endWMState; } } + public double TimestampSecStart { get { return timestampSecStart; } } + public double TimestampSecEnd { get { return timestampSecEnd; } } + + private void ProcessOpticalText(string text) + { + lock (opticalSync) + { + opticalBuffer.Append(text); + + while (true) + { + string buffered = opticalBuffer.ToString(); + int lineEnd = buffered.IndexOf('\n'); + if (lineEnd < 0) + return; + + string line = buffered.Substring(0, lineEnd + 1); + opticalBuffer.Remove(0, lineEnd + 1); + lastOpticalLine = line; + + AllyOpticalSample sample; + if (!AllyOpticalSample.TryParse(line, DateTime.UtcNow, out sample)) + continue; + + ExtendRawVolume(sample.RawVolume); + ExtendRawTimestamp(sample.RawTimestamp); + sample.ExtendedVolumeLiters = + extendedRawVolume * GetOpticalVolumeLitersPerRawUnit(); + sample.ElapsedSeconds = extendedRawTimestamp / 8192D; + + if (opticalSamples.Count == MaxStoredSamples) + opticalSamples.RemoveAt(0); + opticalSamples.Add(sample); + + endWMState = sample.ExtendedVolumeLiters; + timestampSecEnd = sample.ElapsedSeconds; + + if (operationActive && !hasTestStartSample) + { + hasTestStartSample = true; + beginWMState = sample.ExtendedVolumeLiters; + timestampSecStart = sample.ElapsedSeconds; + } + } + } + } + + private void ExtendRawVolume(uint rawVolume) + { + if (!hasPreviousRawVolume) + { + hasPreviousRawVolume = true; + previousRawVolume = rawVolume; + extendedRawVolume = rawVolume; + return; + } + + long delta = (long)rawVolume - previousRawVolume; + if (delta < -RawVolumeHalfRange) + delta += RawVolumeModulo; + else if (delta > RawVolumeHalfRange) + delta -= RawVolumeModulo; + + extendedRawVolume += delta; + previousRawVolume = rawVolume; + } + + private void ExtendRawTimestamp(uint rawTimestamp) + { + if (!hasPreviousRawTimestamp) + { + hasPreviousRawTimestamp = true; + previousRawTimestamp = rawTimestamp; + extendedRawTimestamp = rawTimestamp; + return; + } + + long delta = (long)rawTimestamp - previousRawTimestamp; + if (delta < -RawTimestampHalfRange) + delta += RawTimestampModulo; + else if (delta > RawTimestampHalfRange) + delta -= RawTimestampModulo; + + extendedRawTimestamp += delta; + previousRawTimestamp = rawTimestamp; + } + + private double GetOpticalVolumeLitersPerRawUnit() + { + // The optical format scales volume by flow-tube size: raw / 16000 + // for 5/8", 2 * raw / 16000 for 3/4", and 4 * raw / 16000 for 1". + switch (ConfiguredMeterSize) + { + case AllyMeterSize.FiveEighths: return 1D / 16000D; + case AllyMeterSize.ThreeQuarterShort: + case AllyMeterSize.ThreeQuarterLong: return 2D / 16000D; + case AllyMeterSize.OneInch: return 4D / 16000D; + default: + throw new InvalidOperationException( + "ALLY meter size is AutoDetect. UI-2031 serial-number parsing is required before decoding optical volume."); + } + } + + private void ResetVolumeState() + { + hasPreviousRawVolume = false; + hasPreviousRawTimestamp = false; + hasTestStartSample = false; + previousRawVolume = 0; + previousRawTimestamp = 0; + extendedRawVolume = 0; + extendedRawTimestamp = 0; + beginWMState = 0; + endWMState = 0; + timestampSecStart = 0; + timestampSecEnd = 0; + } + + private AllyCommandService GetCommandService() + { + lock (commandSync) + { + if (commandService != null) + return commandService; + + commandTransport = new AllySerialTransportBuilder() + .WithPort("COM" + allyCfg.CommandComPortNr) + .WithBaudRate(allyCfg.CommandBaudRate) + .WithDataBits(8) + .WithParity(Parity.None) + .WithStopBits(StopBits.One) + .Build(); + commandService = new AllyCommandService(commandTransport); + return commandService; + } + } + + private T ExecuteCommand(Func command, T simulatedValue = default(T)) + { + if (DebugLevel != DebugMode.Normal) + return simulatedValue; + + lock (commandSync) + { + try + { + T value = command(GetCommandService()); + CommFailed = false; + return value; + } + catch + { + CommFailed = true; + CloseCommandTransport(); + throw; + } + } + } + + private void ExecuteCommand(Action command) + { + if (DebugLevel != DebugMode.Normal) + return; + + ExecuteCommand( + service => + { + command(service); + return true; + }); + } + + private void CloseCommandTransport() + { + lock (commandSync) + { + if (commandTransport != null) + commandTransport.Dispose(); + commandTransport = null; + commandService = null; + } + } + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/AllyMeterSize.cs b/TBF/Rig/RegisterReaders/AllyReader/AllyMeterSize.cs new file mode 100644 index 000000000..674ed8c50 --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/AllyMeterSize.cs @@ -0,0 +1,11 @@ +namespace TBF.Rig.RegisterReaders.AllyReader +{ + public enum AllyMeterSize + { + AutoDetect, + FiveEighths, + ThreeQuarterShort, + ThreeQuarterLong, + OneInch + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/AllyOpticalSample.cs b/TBF/Rig/RegisterReaders/AllyReader/AllyOpticalSample.cs new file mode 100644 index 000000000..36ce9f0bd --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/AllyOpticalSample.cs @@ -0,0 +1,81 @@ +using System; +using System.Globalization; + +namespace TBF.Rig.RegisterReaders.AllyReader +{ + /// + /// Validated common portion of the 42-byte optical telegram used by the + /// register-reader pattern referenced by UI-2093. ALLY calibration-only + /// fields are intentionally not inferred without UI-1204/UI-1236. + /// + public sealed class AllyOpticalSample + { + private const int TelegramLength = 42; + + public string RawLine { get; private set; } + public DateTime ReceivedAtUtc { get; private set; } + public short RawFlow { get; private set; } + public uint RawVolume { get; private set; } + public uint RawTimestamp { get; private set; } + public double ExtendedVolumeLiters { get; internal set; } + public double ElapsedSeconds { get; internal set; } + + private AllyOpticalSample() + { + } + + public static bool TryParse( + string line, + DateTime receivedAtUtc, + out AllyOpticalSample sample) + { + sample = null; + if (string.IsNullOrWhiteSpace(line)) + return false; + + if (line.Length < TelegramLength) + return false; + + string telegram = line.Substring(line.Length - TelegramLength, TelegramLength); + if (telegram[6] != '\t' || telegram[11] != '\t' || telegram[16] != '\t' || + telegram[23] != '\t' || telegram[28] != '\t' || telegram[37] != '\t' || + telegram[40] != '\r' || telegram[41] != '\n') + { + return false; + } + + ushort rawFlowUnsigned; + uint rawVolume; + uint rawTimestamp; + byte checksum; + if (!ushort.TryParse(telegram.Substring(12, 4), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out rawFlowUnsigned) || + !uint.TryParse(telegram.Substring(17, 6), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out rawVolume) || + !uint.TryParse(telegram.Substring(29, 8), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out rawTimestamp) || + !byte.TryParse(telegram.Substring(38, 2), NumberStyles.HexNumber, + CultureInfo.InvariantCulture, out checksum)) + { + return false; + } + + byte calculatedChecksum = 0; + for (int i = 0; i < TelegramLength - 4; i++) + calculatedChecksum += (byte)telegram[i]; + + if (calculatedChecksum != checksum || rawVolume > 0xFFFFFF) + return false; + + sample = new AllyOpticalSample + { + RawLine = telegram, + ReceivedAtUtc = receivedAtUtc, + RawFlow = unchecked((short)rawFlowUnsigned), + RawVolume = rawVolume, + RawTimestamp = rawTimestamp + }; + return true; + } + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/AllyReaderCfg.cs b/TBF/Rig/RegisterReaders/AllyReader/AllyReaderCfg.cs new file mode 100644 index 000000000..de2a8172d --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/AllyReaderCfg.cs @@ -0,0 +1,191 @@ +using System.Collections.Generic; +using System.IO.Ports; +using System.Xml.Serialization; +using Common; +using TBF.Rig.Generic; + +namespace TBF.Rig.RegisterReaders.AllyReader +{ + public class AllyReaderCfg : ComponentCfgBase, IComponentCfg, IParamsProvider + { + public static readonly XmlSerializer Serializer = + XmlSerializer.FromTypes(new[] { typeof(AllyReaderCfg) })[0]; + + public int CommandComPortNr; + public int CommandBaudRate; + public int OptoComPortNr; + public int OptoBaudRate; + public AllyMeterSize ConfiguredMeterSize; + public double MeterPulsesPerLiter; + + private AllyReaderCfg() + { + } + + public AllyReaderCfg(IComponentFactory factory) + { + Factory = factory; + Name = "Ally"; + ParentName = string.Empty; + InitializeAll(); + } + + public override XmlSerializer GetSerializer() + { + return Serializer; + } + + public IComponentCfgCtrl GetControl(IList cmpntEntities) + { + return new Configs.ParamsProvider.ComponentCfgCtrl(this, null); + } + + public string ComponentName { get { return Name; } } + + public void InitializeAll() + { + CommandComPortNr = 1; + CommandBaudRate = 2400; + OptoComPortNr = 2; + OptoBaudRate = 9600; + ConfiguredMeterSize = AllyMeterSize.AutoDetect; + MeterPulsesPerLiter = 1000D; + } + + public int ParamsCount() + { + return 6; + } + + public string ParamName(int i) + { + switch (i) + { + case 0: return "Touch-Read COM port"; + case 1: return "Touch-Read baud rate"; + case 2: return "Optical COM port"; + case 3: return "Optical baud rate"; + case 4: return "Configured ALLY meter size"; + case 5: return "Meter pulses per liter"; + default: return string.Empty; + } + } + + public ICollection ParamValues(int i) + { + switch (i) + { + case 1: + return new[] { "1200", "2400", "4800", "9600" }; + case 3: + return new[] { "9600", "19200", "38400", "57600" }; + case 4: + return new[] + { + AllyMeterSize.AutoDetect.ToString(), + AllyMeterSize.FiveEighths.ToString(), + AllyMeterSize.ThreeQuarterShort.ToString(), + AllyMeterSize.ThreeQuarterLong.ToString(), + AllyMeterSize.OneInch.ToString() + }; + default: + return null; + } + } + + public string ToString(int i) + { + switch (i) + { + case 0: return CommandComPortNr.ToString(); + case 1: return CommandBaudRate.ToString(); + case 2: return OptoComPortNr.ToString(); + case 3: return OptoBaudRate.ToString(); + case 4: return ConfiguredMeterSize.ToString(); + case 5: return MeterPulsesPerLiter.ToString(System.Globalization.CultureInfo.InvariantCulture); + default: + return string.Format( + "{0}: Touch-Read=COM{1}/{2}, Optical=COM{3}/{4}, Size={5}", + Name, + CommandComPortNr, + CommandBaudRate, + OptoComPortNr, + OptoBaudRate, + ConfiguredMeterSize); + } + } + + public CfgUpdateFlags UpdateParam(int i, string strValue) + { + switch (i) + { + case 0: CommandComPortNr = int.Parse(strValue); break; + case 1: CommandBaudRate = int.Parse(strValue); break; + case 2: OptoComPortNr = int.Parse(strValue); break; + case 3: OptoBaudRate = int.Parse(strValue); break; + case 4: ConfiguredMeterSize = (AllyMeterSize)System.Enum.Parse(typeof(AllyMeterSize), strValue); break; + case 5: MeterPulsesPerLiter = Utils.ParseUDouble(strValue); break; + default: return CfgUpdateFlags.None; + } + + return CfgUpdateFlags.RestartRqrd; + } + + public bool ValidateParam(int i, string strValue, out string message) + { + message = string.Empty; + int integerValue; + double doubleValue; + + switch (i) + { + case 0: + case 2: + if (int.TryParse(strValue, out integerValue) && integerValue > 0) + return true; + break; + case 1: + case 3: + if (int.TryParse(strValue, out integerValue) && integerValue > 0) + return true; + break; + case 4: + AllyMeterSize meterSize; + if (System.Enum.TryParse(strValue, out meterSize)) + return true; + break; + case 5: + if (double.TryParse(strValue, out doubleValue) && doubleValue > 0) + return true; + break; + default: + message = "Invalid parameter index."; + return false; + } + + message = ParamName(i) + " is invalid."; + return false; + } + + public bool UpdateEmbeddedDbEntity() + { + return true; + } + + public IParamsProvider Clone() + { + return new AllyReaderCfg + { + Name = Name, + ParentName = ParentName, + Factory = Factory, + CommandComPortNr = CommandComPortNr, + CommandBaudRate = CommandBaudRate, + OptoComPortNr = OptoComPortNr, + OptoBaudRate = OptoBaudRate, + ConfiguredMeterSize = ConfiguredMeterSize, + MeterPulsesPerLiter = MeterPulsesPerLiter + }; + } + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyCommandService.cs b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyCommandService.cs new file mode 100644 index 000000000..fa31e7d42 --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyCommandService.cs @@ -0,0 +1,208 @@ +using System; +using System.Globalization; +using System.Linq; + +namespace TBF.Rig.RegisterReaders.AllyReader.Communication +{ + /// + /// ALLY commands required by UI-2093 R9.0. The service deliberately omits + /// iPerl-only Q2, 2 Hz, flip-mode and RFID structure operations. + /// + public sealed class AllyCommandService + { + private static readonly DateTime SystemTimeEpochUtc = + new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private readonly IAllyTransport transport; + private readonly AllyFrameParser parser = new AllyFrameParser(); + + public AllyCommandService(IAllyTransport transport) + { + this.transport = transport ?? throw new ArgumentNullException(nameof(transport)); + } + + public string ReadSerialNumber(int timeoutMs) + { + AllyResponse response = Send( + new AllyFrameBuilder().WithCommand(AllyCommand.ViewFactoryId).BuildBytes(), + timeoutMs); + return response.GetNullTerminatedAscii(); + } + + public AllyVersionInfo ReadVersionAndType(int timeoutMs) + { + AllyResponse response = Send( + new AllyFrameBuilder().WithCommand(AllyCommand.ViewVersionAndType).BuildBytes(), + timeoutMs); + return AllyVersionInfo.Parse(response.GetNullTerminatedAscii()); + } + + public DateTime ReadSystemTimeUtc(int timeoutMs) + { + AllyResponse response = Send( + new AllyFrameBuilder().WithDeviceCommand(AllyDeviceCommand.ViewSystemTime).BuildBytes(), + timeoutMs); + return SystemTimeEpochUtc.AddSeconds(response.GetUInt32LittleEndian()); + } + + public byte ReadRebootCount(int timeoutMs) + { + AllyResponse response = Send( + new AllyFrameBuilder().WithDeviceCommand(AllyDeviceCommand.ViewRebootCount).BuildBytes(), + timeoutMs); + return response.GetByte(); + } + + public void OpenValve(int timeoutMs) + { + Send( + new AllyFrameBuilder() + .WithCommand(AllyCommand.SetValvePosition) + .WithBytes(0x00, 0x02) + .BuildBytes(), + timeoutMs); + } + + public void SetSpreadSpectrum(bool enabled, int timeoutMs) + { + Send( + new AllyFrameBuilder() + .WithDeviceCommand(AllyDeviceCommand.Configuration) + .WithBytes(0x06, enabled ? (byte)0x00 : (byte)0x01) + .BuildBytes(), + timeoutMs); + } + + public void SetMeterMode(byte mode, int timeoutMs) + { + Send( + new AllyFrameBuilder() + .WithCommand(AllyCommand.SetMeterMode) + .WithByte(mode) + .BuildBytes(), + timeoutMs); + } + + public void SetDiagnosticLed(byte mode, int timeoutMs) + { + Send( + new AllyFrameBuilder() + .WithDeviceCommand(AllyDeviceCommand.SetDiagnosticLed) + .WithByte(mode) + .BuildBytes(), + timeoutMs); + } + + public double ReadCalibrationFactorPercent(int timeoutMs) + { + AllyResponse response = Send( + new AllyFrameBuilder().WithDeviceCommand(AllyDeviceCommand.ViewCalibration).BuildBytes(), + timeoutMs); + return response.GetUInt16LittleEndian() / 40.96D; + } + + public void SetCalibrationFactorPercent(double factorPercent, int timeoutMs) + { + if (factorPercent <= 0 || factorPercent > ushort.MaxValue / 40.96D) + throw new ArgumentOutOfRangeException(nameof(factorPercent)); + + ushort rawValue = checked((ushort)Math.Round( + factorPercent * 40.96D, + MidpointRounding.AwayFromZero)); + + Send( + new AllyFrameBuilder() + .WithDeviceCommand(AllyDeviceCommand.SetCalibration) + .WithUInt16LittleEndian(rawValue) + .BuildBytes(), + timeoutMs); + } + + public void SetMagneticTamperProfile(AllyMagneticTamperProfile profile, int timeoutMs) + { + AllyFrameBuilder builder = new AllyFrameBuilder() + .WithDeviceCommand(AllyDeviceCommand.Configuration); + + // Exact UI-2093 frames. The leading byte selects the RCS profile + // (0x02) or the firmware-default profile (0x05). + if (profile == AllyMagneticTamperProfile.Calibration) + builder.WithBytes(0x02, 0x5A, 0x0C, 0x05, 0x3C, 0x50); + else + builder.WithBytes(0x05, 0x1E, 0x04, 0x05, 0x3C, 0x50); + + Send(builder.BuildBytes(), timeoutMs); + } + + public void SetDisplayVolume(string eightDigitVolume, int timeoutMs) + { + if (eightDigitVolume == null || + eightDigitVolume.Length != 8 || + !eightDigitVolume.All(char.IsDigit)) + { + throw new ArgumentException( + "ALLY display volume must contain exactly eight digits.", + nameof(eightDigitVolume)); + } + + Send( + new AllyFrameBuilder() + .WithCommand(AllyCommand.SetPresetTotal) + .WithNullTerminatedAscii(eightDigitVolume) + .BuildBytes(), + timeoutMs); + } + + public void SetLcdTimeout(byte seconds, int timeoutMs) + { + Send( + new AllyFrameBuilder() + .WithDeviceCommand(AllyDeviceCommand.SetLcdTimeout) + .WithByte(seconds) + .BuildBytes(), + timeoutMs); + } + + public void StartOffsetLearning(ushort samples, ushort startDelaySeconds, int timeoutMs) + { + if (samples == 0) + throw new ArgumentOutOfRangeException(nameof(samples)); + + Send( + new AllyFrameBuilder() + .WithDeviceCommand(AllyDeviceCommand.StartOffsetLearning) + .WithUInt16LittleEndian(samples) + .WithUInt16LittleEndian(startDelaySeconds) + .BuildBytes(), + timeoutMs); + } + + private AllyResponse Send(byte[] request, int timeoutMs) + { + try + { + if (!transport.IsOpen) + transport.Open(); + + byte[] rawResponse = transport.SendAndWait(request, timeoutMs); + AllyResponse response = parser.ParseResponse(rawResponse); + if (!response.IsSuccess) + { + throw new AllyCommunicationException( + "ALLY command failed with status 0x" + + response.Status.ToString("X2", CultureInfo.InvariantCulture) + ".", + response.Status); + } + + return response; + } + catch (AllyCommunicationException) + { + throw; + } + catch (Exception ex) + { + throw new AllyCommunicationException("ALLY communication failed.", ex); + } + } + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyFrame.cs b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyFrame.cs new file mode 100644 index 000000000..a74bca8a9 --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyFrame.cs @@ -0,0 +1,31 @@ +using System; + +namespace TBF.Rig.RegisterReaders.AllyReader.Communication +{ + public sealed class AllyFrame + { + public byte Direction { get; private set; } + public byte[] Information { get; private set; } + + public AllyFrame(byte direction, byte[] information) + { + Direction = direction; + Information = information ?? Array.Empty(); + } + + public byte[] ToArray() + { + int length = 4 + Information.Length; + if (length > byte.MaxValue) + throw new InvalidOperationException("ALLY frame is too long."); + + byte[] bytes = new byte[length]; + bytes[0] = AllyProtocol.Start; + bytes[1] = Direction; + bytes[2] = (byte)length; + Buffer.BlockCopy(Information, 0, bytes, 3, Information.Length); + bytes[bytes.Length - 1] = AllyProtocol.End; + return bytes; + } + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyFrameBuilder.cs b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyFrameBuilder.cs new file mode 100644 index 000000000..2088cdb58 --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyFrameBuilder.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace TBF.Rig.RegisterReaders.AllyReader.Communication +{ + /// + /// Builds the 0x53 0x57 <length> ... 0x0D request frames used by + /// the ALLY Touch-Read commands in UI-2093. + /// + public sealed class AllyFrameBuilder + { + private readonly List information = new List(); + + internal AllyFrameBuilder WithCommand(AllyCommand command) + { + EnsureCommandNotSet(); + information.Add((byte)command); + return this; + } + + internal AllyFrameBuilder WithDeviceCommand(AllyDeviceCommand command) + { + EnsureCommandNotSet(); + information.Add(AllyProtocol.DeviceSpecific); + information.Add((byte)command); + return this; + } + + public AllyFrameBuilder WithByte(byte value) + { + EnsureCommandSet(); + information.Add(value); + return this; + } + + public AllyFrameBuilder WithBytes(params byte[] values) + { + EnsureCommandSet(); + if (values != null) + information.AddRange(values); + return this; + } + + public AllyFrameBuilder WithUInt16LittleEndian(ushort value) + { + EnsureCommandSet(); + information.Add((byte)(value & 0xFF)); + information.Add((byte)(value >> 8)); + return this; + } + + public AllyFrameBuilder WithNullTerminatedAscii(string value) + { + EnsureCommandSet(); + if (!string.IsNullOrEmpty(value)) + information.AddRange(Encoding.ASCII.GetBytes(value)); + information.Add(0x00); + return this; + } + + public AllyFrame Build() + { + EnsureCommandSet(); + return new AllyFrame(AllyProtocol.Write, information.ToArray()); + } + + public byte[] BuildBytes() + { + return Build().ToArray(); + } + + private void EnsureCommandSet() + { + if (information.Count == 0) + throw new InvalidOperationException("ALLY command must be set before its payload."); + } + + private void EnsureCommandNotSet() + { + if (information.Count != 0) + throw new InvalidOperationException("ALLY frame already contains a command."); + } + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyFrameParser.cs b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyFrameParser.cs new file mode 100644 index 000000000..1b1c3543a --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyFrameParser.cs @@ -0,0 +1,29 @@ +using System; + +namespace TBF.Rig.RegisterReaders.AllyReader.Communication +{ + public sealed class AllyFrameParser + { + public AllyResponse ParseResponse(byte[] data) + { + if (data == null) + throw new ArgumentNullException(nameof(data)); + if (data.Length < 5) + throw new FormatException("ALLY response is too short."); + if (data[0] != AllyProtocol.Start) + throw new FormatException("ALLY response has an invalid start byte."); + if (data[1] != AllyProtocol.Read) + throw new FormatException("ALLY frame is not a response."); + if (data[2] != data.Length) + throw new FormatException("ALLY response length does not match its length field."); + if (data[data.Length - 1] != AllyProtocol.End) + throw new FormatException("ALLY response has an invalid end byte."); + + byte[] payload = new byte[data.Length - 5]; + if (payload.Length > 0) + Buffer.BlockCopy(data, 4, payload, 0, payload.Length); + + return new AllyResponse(data[3], payload); + } + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyProtocol.cs b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyProtocol.cs new file mode 100644 index 000000000..4c91412c5 --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyProtocol.cs @@ -0,0 +1,62 @@ +using System; + +namespace TBF.Rig.RegisterReaders.AllyReader.Communication +{ + internal static class AllyProtocol + { + public const byte Start = 0x53; + public const byte Write = 0x57; + public const byte Read = 0x52; + public const byte End = 0x0D; + public const byte CommandCompleted = 0x01; + public const byte DeviceSpecific = 0xFD; + } + + internal enum AllyCommand : byte + { + ViewFactoryId = 0x01, + ViewVersionAndType = 0x05, + SetPresetTotal = 0x14, + SetMeterMode = 0x1A, + SetValvePosition = 0x1E + } + + internal enum AllyDeviceCommand : byte + { + ViewSystemTime = 0x10, + Configuration = 0x15, + ViewCalibration = 0x53, + SetCalibration = 0x54, + ViewRebootCount = 0x55, + SetDiagnosticLed = 0x60, + SetLcdTimeout = 0x8C, + StartOffsetLearning = 0xD1 + } + + public enum AllyMagneticTamperProfile + { + Calibration, + FirmwareDefaults + } + + public sealed class AllyCommunicationException : Exception + { + public byte? Status { get; private set; } + + public AllyCommunicationException(string message) + : base(message) + { + } + + public AllyCommunicationException(string message, byte status) + : base(message) + { + Status = status; + } + + public AllyCommunicationException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyResponse.cs b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyResponse.cs new file mode 100644 index 000000000..cd2c1abdf --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyResponse.cs @@ -0,0 +1,51 @@ +using System; +using System.Text; + +namespace TBF.Rig.RegisterReaders.AllyReader.Communication +{ + public sealed class AllyResponse + { + public byte Status { get; private set; } + public byte[] Payload { get; private set; } + public bool IsSuccess { get { return Status == AllyProtocol.CommandCompleted; } } + + internal AllyResponse(byte status, byte[] payload) + { + Status = status; + Payload = payload ?? Array.Empty(); + } + + public string GetNullTerminatedAscii() + { + int length = Array.IndexOf(Payload, (byte)0x00); + if (length < 0) + length = Payload.Length; + return Encoding.ASCII.GetString(Payload, 0, length).Trim(); + } + + public byte GetByte() + { + if (Payload.Length != 1) + throw new FormatException("ALLY response does not contain one byte."); + return Payload[0]; + } + + public ushort GetUInt16LittleEndian() + { + if (Payload.Length != 2) + throw new FormatException("ALLY response does not contain a UInt16 value."); + return (ushort)(Payload[0] | (Payload[1] << 8)); + } + + public uint GetUInt32LittleEndian() + { + if (Payload.Length != 4) + throw new FormatException("ALLY response does not contain a UInt32 value."); + + return (uint)(Payload[0] + | (Payload[1] << 8) + | (Payload[2] << 16) + | (Payload[3] << 24)); + } + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/Communication/AllySerialTransport.cs b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllySerialTransport.cs new file mode 100644 index 000000000..adc445594 --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllySerialTransport.cs @@ -0,0 +1,178 @@ +using System; +using System.Diagnostics; +using System.IO.Ports; + +namespace TBF.Rig.RegisterReaders.AllyReader.Communication +{ + public sealed class AllySerialTransportBuilder + { + private string portName; + private int baudRate = 2400; + private int dataBits = 8; + private Parity parity = Parity.None; + private StopBits stopBits = StopBits.One; + + public AllySerialTransportBuilder WithPort(string value) + { + portName = value; + return this; + } + + public AllySerialTransportBuilder WithBaudRate(int value) + { + baudRate = value; + return this; + } + + public AllySerialTransportBuilder WithDataBits(int value) + { + dataBits = value; + return this; + } + + public AllySerialTransportBuilder WithParity(Parity value) + { + parity = value; + return this; + } + + public AllySerialTransportBuilder WithStopBits(StopBits value) + { + stopBits = value; + return this; + } + + public AllySerialTransport Build() + { + if (string.IsNullOrWhiteSpace(portName)) + throw new InvalidOperationException("ALLY command port is not configured."); + if (baudRate <= 0 || dataBits <= 0) + throw new InvalidOperationException("ALLY serial settings are invalid."); + + return new AllySerialTransport(portName, baudRate, dataBits, parity, stopBits); + } + + public AllySerialTransport BuildAndConnect() + { + AllySerialTransport transport = Build(); + transport.Open(); + return transport; + } + } + + public sealed class AllySerialTransport : IAllyTransport + { + private readonly object sync = new object(); + private readonly string portName; + private readonly int baudRate; + private readonly int dataBits; + private readonly Parity parity; + private readonly StopBits stopBits; + private SerialPort serialPort; + + internal AllySerialTransport( + string portName, + int baudRate, + int dataBits, + Parity parity, + StopBits stopBits) + { + this.portName = portName; + this.baudRate = baudRate; + this.dataBits = dataBits; + this.parity = parity; + this.stopBits = stopBits; + } + + public bool IsOpen { get { return serialPort != null && serialPort.IsOpen; } } + + public void Open() + { + lock (sync) + { + if (IsOpen) + return; + + DisposePort(); + serialPort = new SerialPort(portName, baudRate, parity, dataBits, stopBits); + serialPort.Open(); + } + } + + public byte[] SendAndWait(byte[] request, int timeoutMs) + { + if (request == null || request.Length == 0) + throw new ArgumentException("ALLY request is empty.", nameof(request)); + if (timeoutMs <= 0) + throw new ArgumentOutOfRangeException(nameof(timeoutMs)); + + lock (sync) + { + if (!IsOpen) + Open(); + + serialPort.DiscardInBuffer(); + serialPort.WriteTimeout = timeoutMs; + serialPort.Write(request, 0, request.Length); + + Stopwatch stopwatch = Stopwatch.StartNew(); + int first; + do + { + first = ReadByte(stopwatch, timeoutMs); + } + while (first != AllyProtocol.Start); + + int direction = ReadByte(stopwatch, timeoutMs); + int length = ReadByte(stopwatch, timeoutMs); + if (length < 5) + throw new FormatException("ALLY response length is invalid."); + + byte[] response = new byte[length]; + response[0] = (byte)first; + response[1] = (byte)direction; + response[2] = (byte)length; + + for (int i = 3; i < response.Length; i++) + response[i] = (byte)ReadByte(stopwatch, timeoutMs); + + return response; + } + } + + private int ReadByte(Stopwatch stopwatch, int timeoutMs) + { + int remaining = timeoutMs - (int)stopwatch.ElapsedMilliseconds; + if (remaining <= 0) + throw new TimeoutException("ALLY response timeout."); + + serialPort.ReadTimeout = remaining; + return serialPort.ReadByte(); + } + + public void Dispose() + { + lock (sync) + { + DisposePort(); + } + } + + private void DisposePort() + { + if (serialPort == null) + return; + + try + { + if (serialPort.IsOpen) + serialPort.Close(); + } + finally + { + serialPort.Dispose(); + serialPort = null; + } + } + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyVersionInfo.cs b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyVersionInfo.cs new file mode 100644 index 000000000..ed910f1d9 --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/Communication/AllyVersionInfo.cs @@ -0,0 +1,45 @@ +using System; + +namespace TBF.Rig.RegisterReaders.AllyReader.Communication +{ + public sealed class AllyVersionInfo + { + public string TouchReadVersion { get; private set; } + public string DeviceType { get; private set; } + public string FirmwareVersion { get; private set; } + public string RawValue { get; private set; } + + private AllyVersionInfo() + { + } + + public static AllyVersionInfo Parse(string rawValue) + { + if (string.IsNullOrWhiteSpace(rawValue)) + throw new FormatException("ALLY version/type response is empty."); + + string[] fields = rawValue.Split(','); + if (fields.Length != 3) + throw new FormatException("ALLY version/type response must contain three comma-separated fields."); + + for (int i = 0; i < fields.Length; i++) + fields[i] = fields[i].Trim(); + + if (Array.Exists(fields, string.IsNullOrWhiteSpace)) + throw new FormatException("ALLY version/type response contains an empty field."); + + return new AllyVersionInfo + { + TouchReadVersion = fields[0], + DeviceType = fields[1], + FirmwareVersion = fields[2], + RawValue = rawValue.Trim() + }; + } + + public override string ToString() + { + return RawValue; + } + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/Communication/IAllyTransport.cs b/TBF/Rig/RegisterReaders/AllyReader/Communication/IAllyTransport.cs new file mode 100644 index 000000000..26fe567b8 --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/Communication/IAllyTransport.cs @@ -0,0 +1,11 @@ +using System; + +namespace TBF.Rig.RegisterReaders.AllyReader.Communication +{ + public interface IAllyTransport : IDisposable + { + bool IsOpen { get; } + void Open(); + byte[] SendAndWait(byte[] request, int timeoutMs); + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/Factory.cs b/TBF/Rig/RegisterReaders/AllyReader/Factory.cs new file mode 100644 index 000000000..d46999244 --- /dev/null +++ b/TBF/Rig/RegisterReaders/AllyReader/Factory.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using TBF.Rig.Generic; + +namespace TBF.Rig.RegisterReaders.AllyReader +{ + public class Factory : IComponentFactory + { + public string ClassName { get { return GetType().Namespace.Substring(8); } } + + public override string ToString() + { + return ClassName; + } + + public IComponent DummyComponent() + { + return new AllyMeterReader(); + } + + public IComponent GetComponent(IComponentCfg cfg, IList components) + { + return new AllyMeterReader(cfg); + } + + public IComponentCfg DefaultConfig() + { + return new AllyReaderCfg(this); + } + + public IComponentCfg CmpntCfgFromCmpntEntity(Config.Entities.Component component) + { + return ComponentCfgBase.CreateFromDbEntity(AllyReaderCfg.Serializer, component, this); + } + } +} diff --git a/TBF/Rig/TestMethods/AllyCalibration/AllyCalibrationActivity.cs b/TBF/Rig/TestMethods/AllyCalibration/AllyCalibrationActivity.cs new file mode 100644 index 000000000..49440b4a7 --- /dev/null +++ b/TBF/Rig/TestMethods/AllyCalibration/AllyCalibrationActivity.cs @@ -0,0 +1,86 @@ +namespace TBF.Rig.TestMethods.AllyCalibration +{ + public enum AllyCalibrationActivity + { + ReadSerialNumber, + ReadVersionAndType, + ReadSystemTime, + ReadRebootCount, + OpenValve, + DisableSpreadSpectrum, + SetInitialMeterMode, + TurnDiagnosticLedOn, + ResetCalibrationFactor, + ReadCalibrationFactor, + SetCalibrationMagneticTamperProfile, + TurnDiagnosticLedOff, + SetActiveMeterMode, + EnableSpreadSpectrum, + SetDefaultMagneticTamperProfile, + WriteCalibrationFactor, + WriteDisplayVolume, + SetLcdTimeout, + StartOffsetLearning + } + + internal static class AllyCalibrationActivityNames + { + public const string ReadSerialNumber = "Read serial number"; + public const string ReadVersionAndType = "Read version and type"; + public const string ReadSystemTime = "Read system time"; + public const string ReadRebootCount = "Read reboot count"; + public const string OpenValve = "Open ALLY valve"; + public const string DisableSpreadSpectrum = "Disable spread spectrum"; + public const string SetInitialMeterMode = "Set initial meter mode"; + public const string TurnDiagnosticLedOn = "Turn diagnostic LED on"; + public const string ResetCalibrationFactor = "Reset calibration factor"; + public const string ReadCalibrationFactor = "Read calibration factor"; + public const string SetCalibrationMagneticTamperProfile = "Set calibration magnetic-tamper profile"; + public const string TurnDiagnosticLedOff = "Turn diagnostic LED off"; + public const string SetActiveMeterMode = "Set active meter mode"; + public const string EnableSpreadSpectrum = "Enable spread spectrum"; + public const string SetDefaultMagneticTamperProfile = "Set default magnetic-tamper profile"; + public const string WriteCalibrationFactor = "Write calibration factor"; + public const string WriteDisplayVolume = "Write display volume"; + public const string SetLcdTimeout = "Set LCD timeout"; + public const string StartOffsetLearning = "Start offset learning"; + + public static readonly string[] All = + { + ReadSerialNumber, + ReadVersionAndType, + ReadSystemTime, + ReadRebootCount, + OpenValve, + DisableSpreadSpectrum, + SetInitialMeterMode, + TurnDiagnosticLedOn, + ResetCalibrationFactor, + ReadCalibrationFactor, + SetCalibrationMagneticTamperProfile, + TurnDiagnosticLedOff, + SetActiveMeterMode, + EnableSpreadSpectrum, + SetDefaultMagneticTamperProfile, + WriteCalibrationFactor, + WriteDisplayVolume, + SetLcdTimeout, + StartOffsetLearning + }; + + public static bool TryParse(string value, out AllyCalibrationActivity activity) + { + for (int i = 0; i < All.Length; i++) + { + if (string.Equals(All[i], value, System.StringComparison.OrdinalIgnoreCase)) + { + activity = (AllyCalibrationActivity)i; + return true; + } + } + + activity = default(AllyCalibrationActivity); + return false; + } + } +} diff --git a/TBF/Rig/TestMethods/AllyCalibration/AllyCalibrationSeq.cs b/TBF/Rig/TestMethods/AllyCalibration/AllyCalibrationSeq.cs new file mode 100644 index 000000000..87e0c8e15 --- /dev/null +++ b/TBF/Rig/TestMethods/AllyCalibration/AllyCalibrationSeq.cs @@ -0,0 +1,259 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using Common; +using Config.Entities; +using Results.Entities; +using TBF.Rig.RegisterReaders.AllyReader; +using TBF.Rig.RegisterReaders.AllyReader.Communication; +using TBF.Rig.Sequences; +using TBF.UiBridge; + +namespace TBF.Rig.TestMethods.AllyCalibration +{ + public class AllyCalibrationSeq : SequenceBase + { + private const byte InitialMeterMode = 0x09; + private const byte ActiveMeterMode = 0x02; + private const byte DiagnosticLedCalibrationMode = 0xC2; + private const string DisplayVolume = "01134010"; + private const byte LcdTimeoutSeconds = 30; + private const ushort OffsetLearningSamples = 1000; + private const ushort OffsetLearningDelaySeconds = 1; + + public IList Execute( + Test test, + int repetitionNr, + TestMethod method, + TestMethodCfg cfg, + TestMethodParams parameters) + { + Bridge.OnTestProgress(this, new TestProgressEventArgs(test, Progress.JustStarted)); + + TestRslt testResult = BatchRslts.GetTestRslt(test.Name, 0); + if (testResult != null) + testResult.StartTime = DateTime.Now; + + List messages = new List(); + + for (int position = 0; position < BatchRslts.WMPositionsCount; position++) + { + Results.Entities.WaterMeter waterMeter = BatchRslts.Batch.WaterMeters[position]; + if (waterMeter == null || waterMeter.Disabled) + continue; + + MeterTestRslt meterResult = BatchRslts.GetMeterTestRslt( + test.Name, + position, + CompoundMeterId.Single); + + AllyMeterReader reader = sensPath != null && + sensPath.RegisterReaders != null && + position < sensPath.RegisterReaders.Length + ? sensPath.RegisterReaders[position] as AllyMeterReader + : null; + + bool passed; + string resultMessage; + if (reader == null) + { + passed = false; + resultMessage = "ALLY" + (position + 1) + ": ALLY reader is not configured."; + } + else + { + passed = ExecuteWithRetries(reader, cfg, parameters, out resultMessage); + resultMessage = reader.Name + ": " + resultMessage; + } + + messages.Add(resultMessage); + + if (meterResult != null) + { + meterResult.RegReaderType = (int)RegisterReaderType.DataStream; + meterResult.TestDone = true; + meterResult.Passed = passed; + } + } + + if (testResult != null) + { + testResult.EndTime = DateTime.Now; + testResult.TestDone = true; + testResult.Remark = string.Join("; ", messages); + } + + Bridge.OnTestProgress(this, new TestProgressEventArgs(test, Progress.Completed)); + Bridge.OnTestCompleted(this, new TestCompletedEventArgs(test.Name, testResult)); + allResults.Info(TestResult2CsvLine(test.Name, 0)); + + return new List { Event.Done }; + } + + private static bool ExecuteWithRetries( + AllyMeterReader reader, + TestMethodCfg cfg, + TestMethodParams parameters, + out string resultMessage) + { + Exception lastException = null; + int attempts = Math.Max(1, cfg.MaxAttempts); + + for (int attempt = 1; attempt <= attempts; attempt++) + { + try + { + bool passed = ExecuteOnce(reader, cfg, parameters, out resultMessage); + if (!passed) + return false; + if (attempt > 1) + resultMessage += " (attempt " + attempt + ")"; + return true; + } + catch (Exception ex) + { + lastException = ex; + if (attempt < attempts && cfg.DelayBetweenAttemptsMs > 0) + Thread.Sleep(cfg.DelayBetweenAttemptsMs); + } + } + + resultMessage = lastException == null + ? "ALLY command failed." + : "ALLY command failed after " + attempts + " attempts: " + lastException.Message; + return false; + } + + private static bool ExecuteOnce( + AllyMeterReader reader, + TestMethodCfg cfg, + TestMethodParams parameters, + out string resultMessage) + { + AllyCalibrationActivity activity; + if (!AllyCalibrationActivityNames.TryParse(parameters.Activity, out activity)) + throw new InvalidOperationException("Unknown ALLY calibration activity: " + parameters.Activity); + + int timeout = cfg.CommandTimeoutMs; + switch (activity) + { + case AllyCalibrationActivity.ReadSerialNumber: + string serialNumber = reader.ReadSerialNumber(timeout); + if (string.IsNullOrWhiteSpace(serialNumber)) + throw new FormatException("ALLY serial number response is empty."); + resultMessage = "SerialNumber=" + serialNumber; + return true; + + case AllyCalibrationActivity.ReadVersionAndType: + AllyVersionInfo version = reader.ReadVersionAndType(timeout); + bool deviceTypeOk = string.Equals( + version.DeviceType, + cfg.ExpectedDeviceType, + StringComparison.OrdinalIgnoreCase); + bool firmwareOk = string.IsNullOrWhiteSpace(cfg.ExpectedFirmwareVersion) || + string.Equals( + version.FirmwareVersion, + cfg.ExpectedFirmwareVersion, + StringComparison.OrdinalIgnoreCase); + resultMessage = "Version=" + version + + ", DeviceType=" + (deviceTypeOk ? "OK" : "NOK") + + ", Firmware=" + (firmwareOk ? "OK" : "NOK"); + return deviceTypeOk && firmwareOk; + + case AllyCalibrationActivity.ReadSystemTime: + DateTime systemTime = reader.ReadSystemTimeUtc(timeout); + resultMessage = "SystemTimeUtc=" + systemTime.ToString("O"); + return true; + + case AllyCalibrationActivity.ReadRebootCount: + byte rebootCount = reader.ReadRebootCount(timeout); + resultMessage = "RebootCount=" + rebootCount; + return rebootCount == 0; + + case AllyCalibrationActivity.OpenValve: + reader.OpenValve(timeout); + resultMessage = "Valve=open"; + return true; + + case AllyCalibrationActivity.DisableSpreadSpectrum: + reader.SetSpreadSpectrum(false, timeout); + resultMessage = "SpreadSpectrum=disabled"; + return true; + + case AllyCalibrationActivity.SetInitialMeterMode: + reader.SetMeterMode(InitialMeterMode, timeout); + resultMessage = "MeterMode=0x09"; + return true; + + case AllyCalibrationActivity.TurnDiagnosticLedOn: + reader.SetDiagnosticLed(DiagnosticLedCalibrationMode, timeout); + resultMessage = "DiagnosticLED=0xC2"; + return true; + + case AllyCalibrationActivity.ResetCalibrationFactor: + double resetFactor = reader.ResetCalibrationFactor(timeout); + resultMessage = "ResetCalibrationFactor=" + resetFactor.ToString("F2") + "%"; + return true; + + case AllyCalibrationActivity.ReadCalibrationFactor: + double currentFactor = reader.ReadCalibrationFactor(timeout); + bool factorMatches = !reader.ExpectedCalibrationFactorPercent.HasValue || + Math.Abs(currentFactor - reader.ExpectedCalibrationFactorPercent.Value) <= 0.05D; + resultMessage = "CalibrationFactor=" + currentFactor.ToString("F3") + "%"; + return factorMatches; + + case AllyCalibrationActivity.SetCalibrationMagneticTamperProfile: + reader.SetMagneticTamperProfile(AllyMagneticTamperProfile.Calibration, timeout); + resultMessage = "MagneticTamperProfile=calibration"; + return true; + + case AllyCalibrationActivity.TurnDiagnosticLedOff: + reader.SetDiagnosticLed(0x00, timeout); + resultMessage = "DiagnosticLED=off"; + return true; + + case AllyCalibrationActivity.SetActiveMeterMode: + reader.SetMeterMode(ActiveMeterMode, timeout); + resultMessage = "MeterMode=active"; + return true; + + case AllyCalibrationActivity.EnableSpreadSpectrum: + reader.SetSpreadSpectrum(true, timeout); + resultMessage = "SpreadSpectrum=enabled"; + return true; + + case AllyCalibrationActivity.SetDefaultMagneticTamperProfile: + reader.SetMagneticTamperProfile(AllyMagneticTamperProfile.FirmwareDefaults, timeout); + resultMessage = "MagneticTamperProfile=firmware defaults"; + return true; + + case AllyCalibrationActivity.WriteCalibrationFactor: + reader.SetCalibrationFactor(parameters.CalibrationFactorPercent, timeout); + resultMessage = "CalibrationFactorWritten=" + + parameters.CalibrationFactorPercent.ToString("F3") + "%"; + return true; + + case AllyCalibrationActivity.WriteDisplayVolume: + reader.SetDisplayVolume(DisplayVolume, timeout); + resultMessage = "DisplayVolume=" + DisplayVolume; + return true; + + case AllyCalibrationActivity.SetLcdTimeout: + reader.SetLcdTimeout(LcdTimeoutSeconds, timeout); + resultMessage = "LcdTimeout=30s"; + return true; + + case AllyCalibrationActivity.StartOffsetLearning: + reader.StartOffsetLearning( + OffsetLearningSamples, + OffsetLearningDelaySeconds, + timeout); + resultMessage = "OffsetLearning=1000 samples, 1s delay"; + return true; + + default: + throw new InvalidOperationException("Unsupported ALLY calibration activity."); + } + } + } +} diff --git a/TBF/Rig/TestMethods/AllyCalibration/Factory.cs b/TBF/Rig/TestMethods/AllyCalibration/Factory.cs new file mode 100644 index 000000000..a9b64b74f --- /dev/null +++ b/TBF/Rig/TestMethods/AllyCalibration/Factory.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using TBF.Rig.Generic; + +namespace TBF.Rig.TestMethods.AllyCalibration +{ + public class Factory : IComponentFactory + { + public string ClassName { get { return GetType().Namespace.Substring(8); } } + + public IComponent DummyComponent() + { + return new TestMethod(); + } + + public IComponent GetComponent(IComponentCfg cfg, IList components) + { + return new TestMethod(cfg); + } + + public IComponentCfg DefaultConfig() + { + return new TestMethodCfg(this); + } + + public IComponentCfg CmpntCfgFromCmpntEntity(Config.Entities.Component component) + { + return ComponentCfgBase.CreateFromDbEntity(TestMethodCfg.Serializer, component, this); + } + } +} diff --git a/TBF/Rig/TestMethods/AllyCalibration/TestMethod.cs b/TBF/Rig/TestMethods/AllyCalibration/TestMethod.cs new file mode 100644 index 000000000..9cc10f371 --- /dev/null +++ b/TBF/Rig/TestMethods/AllyCalibration/TestMethod.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using Common; +using Config.Entities; +using log4net; +using TBF.Rig.Generic; +using TBF.Rig.GenericDevices; +using TBF.Rig.Sequences; +using TBF.UiBridge; + +namespace TBF.Rig.TestMethods.AllyCalibration +{ + public class TestMethod : ComponentBase, ISimultTestMethod, ITestMethodSmart + { + private static readonly ILog log = LogManager.GetLogger(typeof(TestMethod)); + private readonly TestMethodCfg allyCfg; + + public TestMethod() + { + } + + public TestMethod(IComponentCfg cfg) + : base(cfg) + { + allyCfg = cfg as TestMethodCfg; + } + + public override string ToString() + { + return Cfg == null ? ClassName : string.Format("{0}({1})", ClassName, Cfg.ToString(-1)); + } + + public FlowType MethodFlowType { get { return FlowType.volume; } } + public bool SimultWithPrevious + { + get + { + TestMethodParams parameters = allyCfg == null ? null : allyCfg.TestParams as TestMethodParams; + return parameters != null && parameters.SimultWithPrevious; + } + } + public bool SimultWithNext + { + get + { + TestMethodParams parameters = allyCfg == null ? null : allyCfg.TestParams as TestMethodParams; + return parameters != null && parameters.SimultWithNext; + } + } + + public bool CanTest(MetersKind meters) + { + return meters == MetersKind.Single; + } + + public bool DoTransitions() + { + return false; + } + + public bool CheckDeviceCaps(Test test, OutputPath devices, out string message) + { + message = string.Empty; + return true; + } + + public override void Initialize() + { + if (allyCfg == null) + throw new InvalidOperationException("ALLY calibration test method configuration is missing."); + + log.FatalFormat("{0} initialized: {1}", Name, this); + } + + public IList Execute(Test test, int repetitionNr, bool isLastRepetition) + { + TestMethodParams parameters = allyCfg.TestParams as TestMethodParams; + if (parameters == null) + throw new InvalidOperationException("ALLY calibration test parameters are missing."); + + if (DebugLevel == DebugMode.Normal) + return new AllyCalibrationSeq().Execute(test, repetitionNr, this, allyCfg, parameters); + + new AllyCalibrationSeq().MakeSimulatedTrivial(test, repetitionNr, test.Part); + Bridge.OnTestProgress(this, new TestProgressEventArgs(test, 1, Progress.Completed)); + Bridge.OnTestCompleted( + this, + new TestCompletedEventArgs( + test.Name, + ProcessData.BatchRslts.GetTestRslt(Common.Utils.GetTestName(test.Name, 1, 1), 0))); + return new List { Event.Done }; + } + } +} diff --git a/TBF/Rig/TestMethods/AllyCalibration/TestMethodCfg.cs b/TBF/Rig/TestMethods/AllyCalibration/TestMethodCfg.cs new file mode 100644 index 000000000..42db4e76b --- /dev/null +++ b/TBF/Rig/TestMethods/AllyCalibration/TestMethodCfg.cs @@ -0,0 +1,186 @@ +using System.Collections.Generic; +using System.IO; +using System.Xml.Serialization; +using Common; +using Config.Entities; +using TBF.Rig.Generic; + +namespace TBF.Rig.TestMethods.AllyCalibration +{ + public class TestMethodCfg : ComponentCfgBase, IComponentCfg, IParamsProvider + { + public static readonly XmlSerializer Serializer = + XmlSerializer.FromTypes(new[] { typeof(TestMethodCfg) })[0]; + + public int CommandTimeoutMs; + public int MaxAttempts; + public int DelayBetweenAttemptsMs; + public string ExpectedDeviceType; + public string ExpectedFirmwareVersion; + + [XmlIgnore] + public ITestParams TestParams { get; set; } + + private TestMethodCfg() + { + } + + public TestMethodCfg(IComponentFactory factory) + { + Factory = factory; + Name = "AllyCalibrationCommunication"; + ParentName = string.Empty; + InitializeAll(); + TestParams = CreateTestParamsProvider() as TestMethodParams; + } + + public override XmlSerializer GetSerializer() + { + return Serializer; + } + + public IComponentCfgCtrl GetControl(IList cmpntEntities) + { + return new Configs.ParamsProvider.ComponentCfgCtrl(this, null); + } + + public override IParamsProvider GetRuntimeTestParamsProvider() + { + return TestParams; + } + + public override IParamsProvider CreateTestParamsProvider() + { + return new TestMethodParams(true); + } + + public override IParamsProvider GetUITestParamsProvider(Test test) + { + return test.Method == Name ? base.GetUITestParamsProvider(test) : null; + } + + public string ComponentName { get { return Name; } } + + public void InitializeAll() + { + CommandTimeoutMs = 5000; + MaxAttempts = 4; + DelayBetweenAttemptsMs = 250; + ExpectedDeviceType = "SWM003"; + ExpectedFirmwareVersion = string.Empty; + } + + public int ParamsCount() + { + return 5; + } + + public string ParamName(int i) + { + switch (i) + { + case 0: return "Command timeout [ms]"; + case 1: return "Maximum command attempts"; + case 2: return "Delay between attempts [ms]"; + case 3: return "Expected ALLY device type"; + case 4: return "Expected firmware version"; + default: return string.Empty; + } + } + + public ICollection ParamValues(int i) + { + return null; + } + + public string ToString(int i) + { + switch (i) + { + case 0: return CommandTimeoutMs.ToString(); + case 1: return MaxAttempts.ToString(); + case 2: return DelayBetweenAttemptsMs.ToString(); + case 3: return ExpectedDeviceType; + case 4: return ExpectedFirmwareVersion; + default: + return string.Format( + "{0}: timeout={1}ms, attempts={2}, device={3}, firmware={4}", + Name, + CommandTimeoutMs, + MaxAttempts, + ExpectedDeviceType, + string.IsNullOrEmpty(ExpectedFirmwareVersion) ? "not checked" : ExpectedFirmwareVersion); + } + } + + public CfgUpdateFlags UpdateParam(int i, string strValue) + { + switch (i) + { + case 0: CommandTimeoutMs = int.Parse(strValue); break; + case 1: MaxAttempts = int.Parse(strValue); break; + case 2: DelayBetweenAttemptsMs = int.Parse(strValue); break; + case 3: ExpectedDeviceType = strValue; break; + case 4: ExpectedFirmwareVersion = strValue; break; + default: return CfgUpdateFlags.None; + } + + return CfgUpdateFlags.RestartRqrd; + } + + public bool ValidateParam(int i, string strValue, out string message) + { + message = string.Empty; + int value; + + switch (i) + { + case 0: + if (int.TryParse(strValue, out value) && value >= 100 && value <= 60000) + return true; + break; + case 1: + if (int.TryParse(strValue, out value) && value >= 1 && value <= 4) + return true; + break; + case 2: + if (int.TryParse(strValue, out value) && value >= 0 && value <= 10000) + return true; + break; + case 3: + if (!string.IsNullOrWhiteSpace(strValue)) + return true; + break; + case 4: + return true; + default: + message = "Invalid parameter index."; + return false; + } + + message = ParamName(i) + " is invalid."; + return false; + } + + public bool UpdateEmbeddedDbEntity() + { + return true; + } + + public IParamsProvider Clone() + { + return new TestMethodCfg + { + Name = Name, + ParentName = ParentName, + Factory = Factory, + CommandTimeoutMs = CommandTimeoutMs, + MaxAttempts = MaxAttempts, + DelayBetweenAttemptsMs = DelayBetweenAttemptsMs, + ExpectedDeviceType = ExpectedDeviceType, + ExpectedFirmwareVersion = ExpectedFirmwareVersion, + TestParams = TestParams == null ? null : TestParams.Clone() as ITestParams + }; + } + } +} diff --git a/TBF/Rig/TestMethods/AllyCalibration/TestMethodParams.cs b/TBF/Rig/TestMethods/AllyCalibration/TestMethodParams.cs new file mode 100644 index 000000000..bbb9502a0 --- /dev/null +++ b/TBF/Rig/TestMethods/AllyCalibration/TestMethodParams.cs @@ -0,0 +1,163 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml.Serialization; +using Common; +using Config.Entities; +using TBF.Rig.Generic; +using TBF.Resources; + +namespace TBF.Rig.TestMethods.AllyCalibration +{ + public class TestMethodParams : TestParamsBase, IParamsProvider, ITestParams + { + public static readonly XmlSerializer Serializer = + XmlSerializer.FromTypes(new[] { typeof(TestMethodParams) })[0]; + + public double CalibrationFactorPercent; + + public override XmlSerializer GetSerializer() + { + return Serializer; + } + + public override void InitializeAll() + { + Activity = AllyCalibrationActivityNames.ReadSerialNumber; + CalibrationFactorPercent = 100D; + SimultWithPrevious = false; + SimultWithNext = false; + } + + public override int ParamsCount() + { + return 4; + } + + public override string ParamName(int i) + { + switch (i) + { + case 0: return Strings.Activity; + case 1: return "Calibration factor [%]"; + case 2: return Strings.Simultaneous_with_previous_step; + case 3: return Strings.Simultaneous_with_next_step; + default: return string.Empty; + } + } + + public override ICollection ParamValues(int i) + { + if (i == 0) + return AllyCalibrationActivityNames.All; + if (i == 2 || i == 3) + return new[] { Strings.yes, Strings.no }; + return null; + } + + public override string ToString(int i) + { + switch (i) + { + case 0: return Activity; + case 1: return CalibrationFactorPercent.ToString(System.Globalization.CultureInfo.InvariantCulture); + case 2: return SimultWithPrevious ? Strings.yes : Strings.no; + case 3: return SimultWithNext ? Strings.yes : Strings.no; + default: return string.Empty; + } + } + + public new CfgUpdateFlags UpdateParam(int i, string strValue) + { + switch (i) + { + case 0: Activity = strValue; break; + case 1: CalibrationFactorPercent = Utils.ParseUDouble(strValue); break; + case 2: SimultWithPrevious = string.Equals(strValue, Strings.yes, StringComparison.OrdinalIgnoreCase); break; + case 3: SimultWithNext = string.Equals(strValue, Strings.yes, StringComparison.OrdinalIgnoreCase); break; + default: return CfgUpdateFlags.None; + } + + return CfgUpdateFlags.None; + } + + public new bool ValidateParam(int i, string strValue, out string message) + { + message = string.Empty; + AllyCalibrationActivity activity; + double factor; + + switch (i) + { + case 0: + if (AllyCalibrationActivityNames.TryParse(strValue, out activity)) + return true; + break; + case 1: + if (double.TryParse(strValue, out factor) && factor > 0D && factor < 1600D) + return true; + break; + case 2: + case 3: + if (string.Equals(strValue, Strings.yes, StringComparison.OrdinalIgnoreCase) || + string.Equals(strValue, Strings.no, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + break; + default: + message = "Invalid parameter index."; + return false; + } + + message = ParamName(i) + " is invalid."; + return false; + } + + public new IParamsProvider Clone() + { + return new TestMethodParams + { + Activity = Activity, + CalibrationFactorPercent = CalibrationFactorPercent, + SimultWithPrevious = SimultWithPrevious, + SimultWithNext = SimultWithNext + }; + } + + public override void UpdateFromDbEntity(ComponentTest dbEntity) + { + if (dbEntity == null) + return; + + try + { + TestMethodParams source = Serializer.Deserialize(new StringReader(dbEntity.Parameters)) as TestMethodParams; + testParamsEntity = dbEntity; + componentName = dbEntity.CmpntName; + test = dbEntity.Test; + + if (source != null) + { + Activity = source.Activity; + CalibrationFactorPercent = source.CalibrationFactorPercent; + SimultWithPrevious = source.SimultWithPrevious; + SimultWithNext = source.SimultWithNext; + } + } + catch + { + } + } + + public TestMethodParams() + { + } + + public TestMethodParams(bool initialize) + { + if (initialize) + InitializeAll(); + } + } +} diff --git a/TBF/TBF.csproj b/TBF/TBF.csproj index c14b62439..523220d10 100644 --- a/TBF/TBF.csproj +++ b/TBF/TBF.csproj @@ -1626,6 +1626,20 @@ UserControl + + + + + + + + + + + + + + GenesisCfgCtrl.cs @@ -1950,6 +1964,12 @@ TestMethodCfgCtrl.cs + + + + + + @@ -4610,4 +4630,4 @@ --> - \ No newline at end of file + diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/AllyMeterReaderTest.cs b/TBFTests/Rig/RegisterReaders/AllyReader/AllyMeterReaderTest.cs new file mode 100644 index 000000000..d15d74298 --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/AllyMeterReaderTest.cs @@ -0,0 +1,125 @@ +using System; +using System.Reflection; +using Common; +using JetBrains.Annotations; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.RegisterReaders.AllyReader; + +namespace TBFTests.Rig.RegisterReaders.AllyReader +{ + [TestClass] + [TestSubject(typeof(AllyMeterReader))] + public class AllyMeterReaderTest + { + [TestMethod] + public void GetResetCalibrationFactorPercent_AllSupportedSizes_ReturnsUi2093Value() + { + Assert.AreEqual(100D, CreateReader(AllyMeterSize.FiveEighths).GetResetCalibrationFactorPercent()); + Assert.AreEqual(100D, CreateReader(AllyMeterSize.ThreeQuarterShort).GetResetCalibrationFactorPercent()); + Assert.AreEqual(100D, CreateReader(AllyMeterSize.ThreeQuarterLong).GetResetCalibrationFactorPercent()); + Assert.AreEqual(260D, CreateReader(AllyMeterSize.OneInch).GetResetCalibrationFactorPercent()); + } + + [TestMethod] + public void IsCalibrationFactorWithinSpecification_BoundariesMatchUi2093() + { + AssertLimits(AllyMeterSize.FiveEighths, 76D, 96D); + AssertLimits(AllyMeterSize.ThreeQuarterShort, 88D, 108D); + AssertLimits(AllyMeterSize.ThreeQuarterLong, 89D, 120D); + AssertLimits(AllyMeterSize.OneInch, 236D, 281D); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void GetResetCalibrationFactorPercent_AutoDetect_ThrowsInvalidOperationException() + { + CreateReader(AllyMeterSize.AutoDetect).GetResetCalibrationFactorPercent(); + } + + [TestMethod] + public void ProcessOpticalText_VolumeAndTimestampRollover_ProducesContinuousMeasurement() + { + AllyMeterReader reader = CreateReader(AllyMeterSize.FiveEighths); + reader.Initialize(); + reader.Start(); + + string input = + AllyOpticalTelegramFactory.Create(10, 0xFFFFF0, 0xFFFFFF00) + + AllyOpticalTelegramFactory.Create(11, 0x000010, 0x00000100); + InvokeProcessOpticalText(reader, input); + reader.Stop(); + + Assert.AreEqual(2, reader.OpticalSamples.Count); + Assert.IsFalse(reader.NoSamples); + Assert.AreEqual(32D / 16000D, reader.WMVolume, 1E-9); + Assert.AreEqual(512D / 8192D, + reader.TimestampSecEnd - reader.TimestampSecStart, 1E-9); + Assert.AreEqual(2, reader.WMPulses); + } + + [TestMethod] + public void Position_NameWithNumericSuffix_ReturnsSuffix() + { + AllyReaderCfg config = CreateConfig(AllyMeterSize.FiveEighths); + config.Name = "Ally12"; + + AllyMeterReader reader = new AllyMeterReader(config); + + Assert.AreEqual(12, reader.Position); + } + + [TestMethod] + public void SetAndReadCalibrationFactor_SimulatedReader_PreservesExpectedValue() + { + AllyMeterReader reader = CreateReader(AllyMeterSize.FiveEighths); + + reader.SetCalibrationFactor(90D, 5000); + double readback = reader.ReadCalibrationFactor(5000); + + Assert.AreEqual(90D, reader.ExpectedCalibrationFactorPercent); + Assert.AreEqual(90D, readback); + Assert.AreEqual(90D, reader.CalibrationFactorPercent); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void SetCalibrationFactor_OutsideConfiguredSizeLimit_ThrowsArgumentOutOfRangeException() + { + CreateReader(AllyMeterSize.FiveEighths).SetCalibrationFactor(100D, 5000); + } + + private static void AssertLimits(AllyMeterSize meterSize, double lower, double upper) + { + AllyMeterReader reader = CreateReader(meterSize); + Assert.IsTrue(reader.IsCalibrationFactorWithinSpecification(lower)); + Assert.IsTrue(reader.IsCalibrationFactorWithinSpecification(upper)); + Assert.IsFalse(reader.IsCalibrationFactorWithinSpecification(lower - 0.01D)); + Assert.IsFalse(reader.IsCalibrationFactorWithinSpecification(upper + 0.01D)); + } + + private static AllyMeterReader CreateReader(AllyMeterSize meterSize) + { + return new AllyMeterReader(CreateConfig(meterSize)); + } + + private static AllyReaderCfg CreateConfig(AllyMeterSize meterSize) + { + return new AllyReaderCfg( + new TBF.Rig.RegisterReaders.AllyReader.Factory()) + { + ConfiguredMeterSize = meterSize, + DebugLevel = DebugMode.Simulate + }; + } + + private static void InvokeProcessOpticalText(AllyMeterReader reader, string text) + { + MethodInfo method = typeof(AllyMeterReader).GetMethod( + "ProcessOpticalText", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.IsNotNull(method, "ProcessOpticalText method was not found."); + method.Invoke(reader, new object[] { text }); + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/AllyOpticalSampleTest.cs b/TBFTests/Rig/RegisterReaders/AllyReader/AllyOpticalSampleTest.cs new file mode 100644 index 000000000..7e4ddecf6 --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/AllyOpticalSampleTest.cs @@ -0,0 +1,70 @@ +using System; +using JetBrains.Annotations; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.RegisterReaders.AllyReader; + +namespace TBFTests.Rig.RegisterReaders.AllyReader +{ + [TestClass] + [TestSubject(typeof(AllyOpticalSample))] + public class AllyOpticalSampleTest + { + [TestMethod] + public void TryParse_ValidTelegram_ParsesFlowVolumeAndTimestamp() + { + string telegram = AllyOpticalTelegramFactory.Create(-2, 0x123456, 0x89ABCDEF); + DateTime receivedAt = new DateTime(2026, 8, 14, 10, 0, 0, DateTimeKind.Utc); + + AllyOpticalSample sample; + bool result = AllyOpticalSample.TryParse(telegram, receivedAt, out sample); + + Assert.IsTrue(result); + Assert.IsNotNull(sample); + Assert.AreEqual((short)-2, sample.RawFlow); + Assert.AreEqual(0x123456U, sample.RawVolume); + Assert.AreEqual(0x89ABCDEFU, sample.RawTimestamp); + Assert.AreEqual(receivedAt, sample.ReceivedAtUtc); + Assert.AreEqual(telegram, sample.RawLine); + } + + [TestMethod] + public void TryParse_PrefixedValidTelegram_UsesLastCompleteTelegram() + { + string telegram = AllyOpticalTelegramFactory.Create(1, 2, 3); + + AllyOpticalSample sample; + bool result = AllyOpticalSample.TryParse("noise" + telegram, DateTime.UtcNow, out sample); + + Assert.IsTrue(result); + Assert.AreEqual(2U, sample.RawVolume); + Assert.AreEqual(3U, sample.RawTimestamp); + Assert.AreEqual(telegram, sample.RawLine); + } + + [TestMethod] + public void TryParse_CorruptChecksum_ReturnsFalse() + { + string telegram = AllyOpticalTelegramFactory.Create(1, 2, 3); + string corrupt = (telegram[0] == '0' ? "1" : "0") + telegram.Substring(1); + + AllyOpticalSample sample; + bool result = AllyOpticalSample.TryParse(corrupt, DateTime.UtcNow, out sample); + + Assert.IsFalse(result); + Assert.IsNull(sample); + } + + [TestMethod] + public void TryParse_InvalidStructure_ReturnsFalse() + { + AllyOpticalSample sample; + bool result = AllyOpticalSample.TryParse( + "000000 0000 0000 000001 0000 00000001 00\r\n", + DateTime.UtcNow, + out sample); + + Assert.IsFalse(result); + Assert.IsNull(sample); + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/AllyOpticalTelegramFactory.cs b/TBFTests/Rig/RegisterReaders/AllyReader/AllyOpticalTelegramFactory.cs new file mode 100644 index 000000000..2f634ccfb --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/AllyOpticalTelegramFactory.cs @@ -0,0 +1,27 @@ +using System.Globalization; + +namespace TBFTests.Rig.RegisterReaders.AllyReader +{ + internal static class AllyOpticalTelegramFactory + { + public static string Create(short rawFlow, uint rawVolume, uint rawTimestamp) + { + string prefix = string.Join("\t", new[] + { + "000000", + "0000", + unchecked((ushort)rawFlow).ToString("X4", CultureInfo.InvariantCulture), + rawVolume.ToString("X6", CultureInfo.InvariantCulture), + "0000", + rawTimestamp.ToString("X8", CultureInfo.InvariantCulture), + string.Empty + }); + + byte checksum = 0; + for (int i = 0; i < prefix.Length; i++) + checksum += (byte)prefix[i]; + + return prefix + checksum.ToString("X2", CultureInfo.InvariantCulture) + "\r\n"; + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/AllyReaderCfgTest.cs b/TBFTests/Rig/RegisterReaders/AllyReader/AllyReaderCfgTest.cs new file mode 100644 index 000000000..a95c04a2c --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/AllyReaderCfgTest.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using System.IO; +using Common; +using JetBrains.Annotations; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.RegisterReaders.AllyReader; + +namespace TBFTests.Rig.RegisterReaders.AllyReader +{ + [TestClass] + [TestSubject(typeof(AllyReaderCfg))] + public class AllyReaderCfgTest + { + [TestMethod] + public void DefaultConfig_ContainsExpectedCommunicationSettings() + { + AllyReaderCfg config = CreateConfig(); + + Assert.AreEqual(1, config.CommandComPortNr); + Assert.AreEqual(2400, config.CommandBaudRate); + Assert.AreEqual(2, config.OptoComPortNr); + Assert.AreEqual(9600, config.OptoBaudRate); + Assert.AreEqual(AllyMeterSize.AutoDetect, config.ConfiguredMeterSize); + Assert.AreEqual(1000D, config.MeterPulsesPerLiter); + Assert.AreEqual(6, config.ParamsCount()); + } + + [TestMethod] + public void MeterSizeParameter_OffersOnlySupportedAllySizes() + { + ICollection values = CreateConfig().ParamValues(4); + + Assert.AreEqual(5, values.Count); + CollectionAssert.Contains((System.Collections.ICollection)values, AllyMeterSize.FiveEighths.ToString()); + CollectionAssert.Contains((System.Collections.ICollection)values, AllyMeterSize.OneInch.ToString()); + } + + [TestMethod] + public void ValidateAndUpdateParameters_ValidValues_UpdateConfiguration() + { + AllyReaderCfg config = CreateConfig(); + string message; + + Assert.IsTrue(config.ValidateParam(0, "7", out message)); + Assert.IsTrue(config.ValidateParam(4, AllyMeterSize.OneInch.ToString(), out message)); + Assert.IsTrue(config.ValidateParam(5, "2000", out message)); + Assert.IsFalse(config.ValidateParam(4, "Unsupported", out message)); + + Assert.AreEqual(CfgUpdateFlags.RestartRqrd, config.UpdateParam(0, "7")); + config.UpdateParam(4, AllyMeterSize.OneInch.ToString()); + config.UpdateParam(5, "2000"); + + Assert.AreEqual(7, config.CommandComPortNr); + Assert.AreEqual(AllyMeterSize.OneInch, config.ConfiguredMeterSize); + Assert.AreEqual(2000D, config.MeterPulsesPerLiter); + } + + [TestMethod] + public void Serializer_RoundTrip_PreservesReaderSettings() + { + AllyReaderCfg source = CreateConfig(); + source.CommandComPortNr = 8; + source.ConfiguredMeterSize = AllyMeterSize.ThreeQuarterLong; + source.MeterPulsesPerLiter = 1234D; + + string xml; + using (StringWriter writer = new StringWriter()) + { + source.GetSerializer().Serialize(writer, source); + xml = writer.ToString(); + } + + AllyReaderCfg restored; + using (StringReader reader = new StringReader(xml)) + restored = (AllyReaderCfg)AllyReaderCfg.Serializer.Deserialize(reader); + + Assert.AreEqual(8, restored.CommandComPortNr); + Assert.AreEqual(AllyMeterSize.ThreeQuarterLong, restored.ConfiguredMeterSize); + Assert.AreEqual(1234D, restored.MeterPulsesPerLiter); + } + + private static AllyReaderCfg CreateConfig() + { + return new AllyReaderCfg(new TBF.Rig.RegisterReaders.AllyReader.Factory()); + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyCommandServiceTest.cs b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyCommandServiceTest.cs new file mode 100644 index 000000000..e12d12efb --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyCommandServiceTest.cs @@ -0,0 +1,179 @@ +using System; +using JetBrains.Annotations; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.RegisterReaders.AllyReader.Communication; + +namespace TBFTests.Rig.RegisterReaders.AllyReader.communication +{ + [TestClass] + [TestSubject(typeof(AllyCommandService))] + public class AllyCommandServiceTest + { + private const int TimeoutMs = 1234; + private FakeAllyTransport transport; + private AllyCommandService service; + + [TestInitialize] + public void TestInitialize() + { + transport = new FakeAllyTransport + { + IsOpen = true, + Response = AllyResponseFactory.CreateSuccess() + }; + service = new AllyCommandService(transport); + } + + [TestMethod] + public void ReadSerialNumber_ValidResponse_ReturnsValueAndBuildsSpecifiedFrame() + { + transport.Response = AllyResponseFactory.CreateAsciiSuccess("100104051"); + + string result = service.ReadSerialNumber(TimeoutMs); + + Assert.AreEqual("100104051", result); + AssertRequest(0x53, 0x57, 0x05, 0x01, 0x0D); + } + + [TestMethod] + public void ReadVersionAndType_ValidResponse_ParsesAllFields() + { + transport.Response = AllyResponseFactory.CreateAsciiSuccess("TR9,SWM003,FW9"); + + AllyVersionInfo result = service.ReadVersionAndType(TimeoutMs); + + Assert.AreEqual("TR9", result.TouchReadVersion); + Assert.AreEqual("SWM003", result.DeviceType); + Assert.AreEqual("FW9", result.FirmwareVersion); + AssertRequest(0x53, 0x57, 0x05, 0x05, 0x0D); + } + + [TestMethod] + public void ReadSystemTimeAndRebootCount_ValidResponses_ParseLittleEndianPayloads() + { + transport.Response = AllyResponseFactory.CreateSuccess(0x80, 0x51, 0x01, 0x00); + DateTime systemTime = service.ReadSystemTimeUtc(TimeoutMs); + Assert.AreEqual(new DateTime(2000, 1, 2, 0, 0, 0, DateTimeKind.Utc), systemTime); + AssertRequest(0x53, 0x57, 0x06, 0xFD, 0x10, 0x0D); + + transport.Response = AllyResponseFactory.CreateSuccess(0x00); + byte rebootCount = service.ReadRebootCount(TimeoutMs); + Assert.AreEqual((byte)0, rebootCount); + AssertRequest(0x53, 0x57, 0x06, 0xFD, 0x55, 0x0D); + } + + [TestMethod] + public void SetupCommands_BuildExactUi2093Frames() + { + service.OpenValve(TimeoutMs); + AssertRequest(0x53, 0x57, 0x07, 0x1E, 0x00, 0x02, 0x0D); + + service.SetSpreadSpectrum(false, TimeoutMs); + AssertRequest(0x53, 0x57, 0x08, 0xFD, 0x15, 0x06, 0x01, 0x0D); + + service.SetSpreadSpectrum(true, TimeoutMs); + AssertRequest(0x53, 0x57, 0x08, 0xFD, 0x15, 0x06, 0x00, 0x0D); + + service.SetMeterMode(0x09, TimeoutMs); + AssertRequest(0x53, 0x57, 0x06, 0x1A, 0x09, 0x0D); + + service.SetDiagnosticLed(0xC2, TimeoutMs); + AssertRequest(0x53, 0x57, 0x07, 0xFD, 0x60, 0xC2, 0x0D); + } + + [TestMethod] + public void CalibrationFactor_WriteAndRead_UsesFactorTimes40Point96Encoding() + { + service.SetCalibrationFactorPercent(100D, TimeoutMs); + AssertRequest(0x53, 0x57, 0x08, 0xFD, 0x54, 0x00, 0x10, 0x0D); + + transport.Response = AllyResponseFactory.CreateSuccess(0x00, 0x10); + double result = service.ReadCalibrationFactorPercent(TimeoutMs); + + Assert.AreEqual(100D, result, 1E-9); + AssertRequest(0x53, 0x57, 0x06, 0xFD, 0x53, 0x0D); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void SetCalibrationFactorPercent_Zero_ThrowsArgumentOutOfRangeException() + { + service.SetCalibrationFactorPercent(0D, TimeoutMs); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void SetDisplayVolume_InvalidDigitCount_ThrowsArgumentException() + { + service.SetDisplayVolume("123", TimeoutMs); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentOutOfRangeException))] + public void StartOffsetLearning_ZeroSamples_ThrowsArgumentOutOfRangeException() + { + service.StartOffsetLearning(0, 1, TimeoutMs); + } + + [TestMethod] + public void MagneticTamperProfiles_BuildExactUi2093Frames() + { + service.SetMagneticTamperProfile(AllyMagneticTamperProfile.Calibration, TimeoutMs); + AssertRequest(0x53, 0x57, 0x0C, 0xFD, 0x15, 0x02, 0x5A, 0x0C, 0x05, 0x3C, 0x50, 0x0D); + + service.SetMagneticTamperProfile(AllyMagneticTamperProfile.FirmwareDefaults, TimeoutMs); + AssertRequest(0x53, 0x57, 0x0C, 0xFD, 0x15, 0x05, 0x1E, 0x04, 0x05, 0x3C, 0x50, 0x0D); + } + + [TestMethod] + public void FinalizationCommands_BuildExactUi2093Frames() + { + service.SetDisplayVolume("01134010", TimeoutMs); + AssertRequest( + 0x53, 0x57, 0x0E, 0x14, + 0x30, 0x31, 0x31, 0x33, 0x34, 0x30, 0x31, 0x30, + 0x00, 0x0D); + + service.SetLcdTimeout(30, TimeoutMs); + AssertRequest(0x53, 0x57, 0x07, 0xFD, 0x8C, 0x1E, 0x0D); + + service.StartOffsetLearning(1000, 1, TimeoutMs); + AssertRequest(0x53, 0x57, 0x0A, 0xFD, 0xD1, 0xE8, 0x03, 0x01, 0x00, 0x0D); + } + + [TestMethod] + public void Send_ClosedTransport_OpensTransportAndPassesTimeout() + { + transport.IsOpen = false; + + service.OpenValve(TimeoutMs); + + Assert.AreEqual(1, transport.OpenCallCount); + Assert.AreEqual(1, transport.SendAndWaitCallCount); + Assert.AreEqual(TimeoutMs, transport.LastTimeoutMs); + } + + [TestMethod] + public void Send_FailureStatus_ThrowsCommunicationExceptionWithStatus() + { + transport.Response = AllyResponseFactory.CreateFailure(0xFD); + + try + { + service.OpenValve(TimeoutMs); + Assert.Fail("Expected AllyCommunicationException."); + } + catch (AllyCommunicationException exception) + { + Assert.AreEqual((byte?)0xFD, exception.Status); + StringAssert.Contains(exception.Message, "0xFD"); + } + } + + private void AssertRequest(params byte[] expected) + { + CollectionAssert.AreEqual(expected, transport.LastRequest); + Assert.AreEqual(TimeoutMs, transport.LastTimeoutMs); + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyFrameParserTest.cs b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyFrameParserTest.cs new file mode 100644 index 000000000..15a71931b --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyFrameParserTest.cs @@ -0,0 +1,56 @@ +using System; +using JetBrains.Annotations; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.RegisterReaders.AllyReader.Communication; + +namespace TBFTests.Rig.RegisterReaders.AllyReader.communication +{ + [TestClass] + [TestSubject(typeof(AllyFrameParser))] + public class AllyFrameParserTest + { + [TestMethod] + public void ParseResponse_ValidFrame_ReturnsStatusAndPayload() + { + AllyFrameParser parser = new AllyFrameParser(); + + AllyResponse response = parser.ParseResponse( + new byte[] { 0x53, 0x52, 0x07, 0x01, 0x34, 0x12, 0x0D }); + + Assert.IsTrue(response.IsSuccess); + Assert.AreEqual((byte)0x01, response.Status); + Assert.AreEqual((ushort)0x1234, response.GetUInt16LittleEndian()); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void ParseResponse_Null_ThrowsArgumentNullException() + { + new AllyFrameParser().ParseResponse(null); + } + + [TestMethod] + [ExpectedException(typeof(FormatException))] + public void ParseResponse_InvalidLength_ThrowsFormatException() + { + new AllyFrameParser().ParseResponse( + new byte[] { 0x53, 0x52, 0x06, 0x01, 0x0D }); + } + + [TestMethod] + [ExpectedException(typeof(FormatException))] + public void ParseResponse_InvalidDirection_ThrowsFormatException() + { + new AllyFrameParser().ParseResponse( + new byte[] { 0x53, 0x57, 0x05, 0x01, 0x0D }); + } + + [TestMethod] + [ExpectedException(typeof(FormatException))] + public void ParseResponse_InvalidEndByte_ThrowsFormatException() + { + new AllyFrameParser().ParseResponse( + new byte[] { 0x53, 0x52, 0x05, 0x01, 0x00 }); + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyFrameTest.cs b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyFrameTest.cs new file mode 100644 index 000000000..fecdf45b3 --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyFrameTest.cs @@ -0,0 +1,38 @@ +using System; +using JetBrains.Annotations; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.RegisterReaders.AllyReader.Communication; + +namespace TBFTests.Rig.RegisterReaders.AllyReader.communication +{ + [TestClass] + [TestSubject(typeof(AllyFrame))] + public class AllyFrameTest + { + [TestMethod] + public void ToArray_InformationBytes_CalculatesLengthAndAddsEnvelope() + { + AllyFrame frame = new AllyFrame(0x57, new byte[] { 0xFD, 0x54, 0x00, 0x10 }); + + byte[] result = frame.ToArray(); + + CollectionAssert.AreEqual( + new byte[] { 0x53, 0x57, 0x08, 0xFD, 0x54, 0x00, 0x10, 0x0D }, + result); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void ToArray_InformationExceedsProtocolLength_ThrowsInvalidOperationException() + { + new AllyFrame(0x57, new byte[252]).ToArray(); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void FrameBuilder_PayloadBeforeCommand_ThrowsInvalidOperationException() + { + new AllyFrameBuilder().WithByte(0x01); + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyResponseFactory.cs b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyResponseFactory.cs new file mode 100644 index 000000000..07a420e44 --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyResponseFactory.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; + +namespace TBFTests.Rig.RegisterReaders.AllyReader.communication +{ + internal static class AllyResponseFactory + { + public static byte[] CreateSuccess(params byte[] payload) + { + return Create(0x01, payload); + } + + public static byte[] CreateFailure(byte status, params byte[] payload) + { + return Create(status, payload); + } + + public static byte[] CreateAsciiSuccess(string value) + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + List payload = new List(System.Text.Encoding.ASCII.GetBytes(value)); + payload.Add(0x00); + return CreateSuccess(payload.ToArray()); + } + + private static byte[] Create(byte status, params byte[] payload) + { + payload = payload ?? Array.Empty(); + List response = new List + { + 0x53, + 0x52, + 0x00, + status + }; + + response.AddRange(payload); + response.Add(0x0D); + response[2] = checked((byte)response.Count); + return response.ToArray(); + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllySerialTransportBuilderTest.cs b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllySerialTransportBuilderTest.cs new file mode 100644 index 000000000..39d5b2cdf --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllySerialTransportBuilderTest.cs @@ -0,0 +1,41 @@ +using System; +using JetBrains.Annotations; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.RegisterReaders.AllyReader.Communication; + +namespace TBFTests.Rig.RegisterReaders.AllyReader.communication +{ + [TestClass] + [TestSubject(typeof(AllySerialTransportBuilder))] + public class AllySerialTransportBuilderTest + { + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void Build_MissingPort_ThrowsInvalidOperationException() + { + new AllySerialTransportBuilder().Build(); + } + + [TestMethod] + [ExpectedException(typeof(InvalidOperationException))] + public void Build_InvalidBaudRate_ThrowsInvalidOperationException() + { + new AllySerialTransportBuilder() + .WithPort("COM1") + .WithBaudRate(0) + .Build(); + } + + [TestMethod] + public void Build_ValidSettings_CreatesClosedTransportWithoutOpeningHardware() + { + AllySerialTransport transport = new AllySerialTransportBuilder() + .WithPort("COM1") + .WithBaudRate(2400) + .Build(); + + Assert.IsFalse(transport.IsOpen); + transport.Dispose(); + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyVersionInfoTest.cs b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyVersionInfoTest.cs new file mode 100644 index 000000000..359c8cc15 --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyVersionInfoTest.cs @@ -0,0 +1,37 @@ +using System; +using JetBrains.Annotations; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.RegisterReaders.AllyReader.Communication; + +namespace TBFTests.Rig.RegisterReaders.AllyReader.communication +{ + [TestClass] + [TestSubject(typeof(AllyVersionInfo))] + public class AllyVersionInfoTest + { + [TestMethod] + public void Parse_ThreeFields_TrimsAndMapsValues() + { + AllyVersionInfo result = AllyVersionInfo.Parse(" TR9 , SWM003 , FW9 "); + + Assert.AreEqual("TR9", result.TouchReadVersion); + Assert.AreEqual("SWM003", result.DeviceType); + Assert.AreEqual("FW9", result.FirmwareVersion); + Assert.AreEqual("TR9 , SWM003 , FW9", result.RawValue); + } + + [TestMethod] + [ExpectedException(typeof(FormatException))] + public void Parse_MissingField_ThrowsFormatException() + { + AllyVersionInfo.Parse("TR9,SWM003"); + } + + [TestMethod] + [ExpectedException(typeof(FormatException))] + public void Parse_EmptyField_ThrowsFormatException() + { + AllyVersionInfo.Parse("TR9,,FW9"); + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/communication/FakeAllyTransport.cs b/TBFTests/Rig/RegisterReaders/AllyReader/communication/FakeAllyTransport.cs new file mode 100644 index 000000000..fe6c832fb --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/communication/FakeAllyTransport.cs @@ -0,0 +1,35 @@ +using TBF.Rig.RegisterReaders.AllyReader.Communication; + +namespace TBFTests.Rig.RegisterReaders.AllyReader.communication +{ + internal sealed class FakeAllyTransport : IAllyTransport + { + public bool IsOpen { get; set; } + public int OpenCallCount { get; private set; } + public int SendAndWaitCallCount { get; private set; } + public int DisposeCallCount { get; private set; } + public byte[] Response { get; set; } + public byte[] LastRequest { get; private set; } + public int LastTimeoutMs { get; private set; } + + public void Open() + { + OpenCallCount++; + IsOpen = true; + } + + public byte[] SendAndWait(byte[] request, int timeoutMs) + { + SendAndWaitCallCount++; + LastRequest = request; + LastTimeoutMs = timeoutMs; + return Response; + } + + public void Dispose() + { + DisposeCallCount++; + IsOpen = false; + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/integration/AllyHardwareIntegrationSettings.cs b/TBFTests/Rig/RegisterReaders/AllyReader/integration/AllyHardwareIntegrationSettings.cs new file mode 100644 index 000000000..2449777ad --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/integration/AllyHardwareIntegrationSettings.cs @@ -0,0 +1,97 @@ +using System; +using Common; +using TBF.Rig.RegisterReaders.AllyReader; + +namespace TBFTests.Rig.RegisterReaders.AllyReader.Integration +{ + internal sealed class AllyHardwareIntegrationSettings + { + public bool Enabled { get; private set; } + public int CommandPortNumber { get; private set; } + public int OpticalPortNumber { get; private set; } + public int CommandBaudRate { get; private set; } + public int OpticalBaudRate { get; private set; } + public int CommandTimeoutMs { get; private set; } + public int OpticalCaptureSeconds { get; private set; } + public int ActiveModeSettleMs { get; private set; } + public AllyMeterSize MeterSize { get; private set; } + + public string CommandPortName { get { return "COM" + CommandPortNumber; } } + public string OpticalPortName { get { return "COM" + OpticalPortNumber; } } + + public static AllyHardwareIntegrationSettings FromEnvironment() + { + return new AllyHardwareIntegrationSettings + { + Enabled = ParseEnabled(Environment.GetEnvironmentVariable("ALLY_HW_TESTS")), + CommandPortNumber = ParsePort("ALLY_COMMAND_PORT", "COM3"), + OpticalPortNumber = ParsePort("ALLY_OPTICAL_PORT", "COM4"), + CommandBaudRate = ParsePositiveInt("ALLY_COMMAND_BAUD", 2400), + OpticalBaudRate = ParsePositiveInt("ALLY_OPTICAL_BAUD", 9600), + CommandTimeoutMs = ParsePositiveInt("ALLY_COMMAND_TIMEOUT_MS", 5000), + OpticalCaptureSeconds = ParsePositiveInt("ALLY_OPTICAL_CAPTURE_SECONDS", 10), + ActiveModeSettleMs = ParsePositiveInt("ALLY_ACTIVE_SETTLE_MS", 1000), + MeterSize = ParseMeterSize(Environment.GetEnvironmentVariable("ALLY_METER_SIZE")) + }; + } + + public AllyReaderCfg CreateReaderConfig() + { + return new AllyReaderCfg(new TBF.Rig.RegisterReaders.AllyReader.Factory()) + { + CommandComPortNr = CommandPortNumber, + CommandBaudRate = CommandBaudRate, + OptoComPortNr = OpticalPortNumber, + OptoBaudRate = OpticalBaudRate, + ConfiguredMeterSize = MeterSize, + MeterPulsesPerLiter = 1000D, + DebugLevel = DebugMode.Normal + }; + } + + private static bool ParseEnabled(string value) + { + return string.Equals(value, "1", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || + string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase); + } + + private static int ParsePort(string variableName, string defaultValue) + { + string value = Environment.GetEnvironmentVariable(variableName) ?? defaultValue; + if (value.StartsWith("COM", StringComparison.OrdinalIgnoreCase)) + value = value.Substring(3); + + int portNumber; + if (!int.TryParse(value, out portNumber) || portNumber <= 0) + throw new InvalidOperationException(variableName + " must contain COM or a positive port number."); + return portNumber; + } + + private static int ParsePositiveInt(string variableName, int defaultValue) + { + string value = Environment.GetEnvironmentVariable(variableName); + if (string.IsNullOrWhiteSpace(value)) + return defaultValue; + + int parsed; + if (!int.TryParse(value, out parsed) || parsed <= 0) + throw new InvalidOperationException(variableName + " must be a positive integer."); + return parsed; + } + + private static AllyMeterSize ParseMeterSize(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return AllyMeterSize.FiveEighths; + + AllyMeterSize result; + if (!Enum.TryParse(value, true, out result) || result == AllyMeterSize.AutoDetect) + { + throw new InvalidOperationException( + "ALLY_METER_SIZE must be a supported explicit size; AutoDetect requires UI-2031 parsing."); + } + return result; + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/integration/AllyHardwareIntegrationTest.cs b/TBFTests/Rig/RegisterReaders/AllyReader/integration/AllyHardwareIntegrationTest.cs new file mode 100644 index 000000000..b6159c9ac --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/integration/AllyHardwareIntegrationTest.cs @@ -0,0 +1,331 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO.Ports; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.RegisterReaders.AllyReader; +using TBF.Rig.RegisterReaders.AllyReader.Communication; + +namespace TBFTests.Rig.RegisterReaders.AllyReader.Integration +{ + [TestClass] + [TestCategory("HardwareIntegration")] + [DoNotParallelize] + public class AllyHardwareIntegrationTest + { + private const byte ActiveMeterMode = 0x02; + private const byte InitialMeterMode = 0x09; + + private AllyHardwareIntegrationSettings settings; + private AllyIntegrationTestLogger logger; + + public TestContext TestContext { get; set; } + + [TestInitialize] + public void InitializeTest() + { + settings = AllyHardwareIntegrationSettings.FromEnvironment(); + logger = new AllyIntegrationTestLogger(TestContext); + logger.Log(string.Format( + CultureInfo.InvariantCulture, + "Configuration | command={0}/{1}, optical={2}/{3}, size={4}, timeout={5} ms, capture={6} s", + settings.CommandPortName, + settings.CommandBaudRate, + settings.OpticalPortName, + settings.OpticalBaudRate, + settings.MeterSize, + settings.CommandTimeoutMs, + settings.OpticalCaptureSeconds)); + + if (!settings.Enabled) + { + logger.Log("SKIP | Hardware tests are disabled. Set ALLY_HW_TESTS=1 to enable them."); + Assert.Inconclusive("Set ALLY_HW_TESTS=1 to run ALLY hardware integration tests."); + } + } + + [TestCleanup] + public void CleanupTest() + { + if (logger != null) + logger.Dispose(); + } + + [TestMethod] + public void CommandPort_ReadIdentityAndHealth_LogsParsedResponses() + { + RequirePorts(settings.CommandPortName); + AllyMeterReader reader = CreateReader(); + reader.StartSession(); + try + { + string serialNumber = logger.Step( + "Read manufacturing serial number", + () => reader.ReadSerialNumber(settings.CommandTimeoutMs)); + Assert.IsFalse(string.IsNullOrWhiteSpace(serialNumber), "The manufacturing serial number is empty."); + + AllyVersionInfo version = logger.Step( + "Read and parse version/type", + () => reader.ReadVersionAndType(settings.CommandTimeoutMs), + value => string.Format( + CultureInfo.InvariantCulture, + "raw={0}; TouchRead={1}; type={2}; firmware={3}; parse=PASS", + value.RawValue, + value.TouchReadVersion, + value.DeviceType, + value.FirmwareVersion)); + Assert.AreEqual("SWM003", version.DeviceType, "The connected device is not an ALLY meter."); + + logger.Step("Read meter system time (UTC)", () => reader.ReadSystemTimeUtc(settings.CommandTimeoutMs), + value => value.ToString("O", CultureInfo.InvariantCulture) + "; parse=PASS"); + logger.Step("Read reboot count", () => reader.ReadRebootCount(settings.CommandTimeoutMs), + value => value.ToString(CultureInfo.InvariantCulture) + "; parse=PASS"); + } + finally + { + reader.EndSession(); + logger.Log("SESSION | command port closed"); + } + } + + [TestMethod] + public void CommandPort_ActiveModeRoundTrip_AlwaysRestoresInitialMode() + { + RequirePorts(settings.CommandPortName); + AllyMeterReader reader = CreateReader(); + reader.StartSession(); + try + { + logger.Step( + "Set active meter mode 0x02", + () => reader.SetMeterMode(ActiveMeterMode, settings.CommandTimeoutMs)); + logger.Log("WAIT | active-mode settling for " + settings.ActiveModeSettleMs + " ms"); + Thread.Sleep(settings.ActiveModeSettleMs); + } + finally + { + try + { + logger.Step( + "Restore initial meter mode 0x09", + () => reader.SetMeterMode(InitialMeterMode, settings.CommandTimeoutMs)); + } + finally + { + reader.EndSession(); + logger.Log("SESSION | command port closed"); + } + } + } + + [TestMethod] + public void OpticalPort_CaptureAndParseTelegrams_LogsRawAndParseResult() + { + RequirePorts(settings.OpticalPortName); + IList samples = CaptureOpticalTelegramsDirectly(); + Assert.IsTrue(samples.Count > 0, "No valid ALLY optical telegram was parsed."); + } + + [TestMethod] + public void Complex_ReadSerialActivateCaptureParseAndDeactivate_LogsWholeWorkflow() + { + RequirePorts(settings.CommandPortName, settings.OpticalPortName); + AllyMeterReader reader = CreateReader(); + bool streamStarted = false; + reader.StartSession(); + try + { + string serialNumber = logger.Step( + "Read manufacturing serial number", + () => reader.ReadSerialNumber(settings.CommandTimeoutMs)); + Assert.IsFalse(string.IsNullOrWhiteSpace(serialNumber), "The manufacturing serial number is empty."); + + logger.Step( + "Set active meter mode 0x02", + () => reader.SetMeterMode(ActiveMeterMode, settings.CommandTimeoutMs)); + logger.Log("WAIT | active-mode settling for " + settings.ActiveModeSettleMs + " ms"); + Thread.Sleep(settings.ActiveModeSettleMs); + + logger.Step("Open optical stream and start measurement", reader.Start); + streamStarted = true; + CaptureThroughReader(reader); + + IReadOnlyList samples = reader.OpticalSamples; + Assert.IsTrue(samples.Count >= 2, "At least two valid optical samples are required for a measurement."); + Assert.IsFalse(reader.NoSamples, "The reader did not establish a valid optical measurement interval."); + logger.Log(string.Format( + CultureInfo.InvariantCulture, + "MEASUREMENT | PASS | serial={0}, samples={1}, volume={2:R} l, elapsed={3:R} s", + serialNumber, + samples.Count, + reader.WMVolume, + reader.TimestampSecEnd - reader.TimestampSecStart)); + } + finally + { + try + { + if (streamStarted) + logger.Step("Stop optical measurement and close COM4", reader.Stop); + } + finally + { + try + { + logger.Step( + "Restore initial meter mode 0x09", + () => reader.SetMeterMode(InitialMeterMode, settings.CommandTimeoutMs)); + } + finally + { + reader.EndSession(); + logger.Log("SESSION | all ports closed"); + } + } + } + } + + private AllyMeterReader CreateReader() + { + AllyMeterReader reader = new AllyMeterReader(settings.CreateReaderConfig()); + logger.Step("Initialize ALLY reader", reader.Initialize); + return reader; + } + + private IList CaptureOpticalTelegramsDirectly() + { + List samples = new List(); + StringBuilder buffer = new StringBuilder(); + int parsedLines = 0; + int rejectedLines = 0; + + using (SerialPort port = new SerialPort( + settings.OpticalPortName, + settings.OpticalBaudRate, + Parity.None, + 8, + StopBits.One)) + { + logger.Step("Open optical source " + settings.OpticalPortName, port.Open); + try + { + port.DiscardInBuffer(); + Stopwatch stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < TimeSpan.FromSeconds(settings.OpticalCaptureSeconds)) + { + string text = port.ReadExisting(); + if (!string.IsNullOrEmpty(text)) + buffer.Append(text); + + string line; + while (TryTakeLine(buffer, out line)) + { + AllyOpticalSample sample; + bool parsed = AllyOpticalSample.TryParse(line, DateTime.UtcNow, out sample); + logger.OpticalParse(line, parsed, sample); + if (parsed) + { + parsedLines++; + samples.Add(sample); + } + else + { + rejectedLines++; + } + } + Thread.Sleep(50); + } + } + finally + { + port.Close(); + logger.Log("PORT | optical source closed"); + } + } + + if (buffer.Length > 0) + { + logger.Log("OPTO PARTIAL | trailing incomplete data: " + buffer.ToString() + .Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t")); + } + logger.Log(string.Format( + CultureInfo.InvariantCulture, + "OPTO SUMMARY | valid={0}, rejected={1}, partialChars={2}", + parsedLines, + rejectedLines, + buffer.Length)); + return samples; + } + + private void CaptureThroughReader(AllyMeterReader reader) + { + int loggedSampleCount = 0; + string lastRawLine = null; + Stopwatch stopwatch = Stopwatch.StartNew(); + while (stopwatch.Elapsed < TimeSpan.FromSeconds(settings.OpticalCaptureSeconds)) + { + reader.RunDeviceBefore(); + + string rawLine = reader.ReadOptoData(); + if (!string.IsNullOrEmpty(rawLine) && rawLine != lastRawLine) + { + AllyOpticalSample parsedSample; + bool parsed = AllyOpticalSample.TryParse(rawLine, DateTime.UtcNow, out parsedSample); + logger.OpticalParse(rawLine, parsed, parsedSample); + lastRawLine = rawLine; + } + + IReadOnlyList currentSamples = reader.OpticalSamples; + while (loggedSampleCount < currentSamples.Count) + { + AllyOpticalSample sample = currentSamples[loggedSampleCount++]; + logger.Log(string.Format( + CultureInfo.InvariantCulture, + "OPTO SAMPLE | index={0}, flow={1}, rawVolume={2}, rawTimestamp={3}, liters={4:R}, seconds={5:R}", + loggedSampleCount, + sample.RawFlow, + sample.RawVolume, + sample.RawTimestamp, + sample.ExtendedVolumeLiters, + sample.ElapsedSeconds)); + } + Thread.Sleep(50); + } + logger.Log("OPTO CAPTURE | completed; parsed samples=" + loggedSampleCount); + } + + private void RequirePorts(params string[] requiredPorts) + { + string[] available = SerialPort.GetPortNames(); + logger.Log("PORTS | available=" + (available.Length == 0 ? "" : string.Join(", ", available))); + string[] missing = requiredPorts + .Where(required => !available.Contains(required, StringComparer.OrdinalIgnoreCase)) + .ToArray(); + if (missing.Length == 0) + return; + + string message = "Required ALLY port(s) are not connected: " + string.Join(", ", missing) + "."; + logger.Log("SKIP | " + message); + Assert.Inconclusive(message); + } + + private static bool TryTakeLine(StringBuilder buffer, out string line) + { + string text = buffer.ToString(); + int lineEnd = text.IndexOf('\n'); + if (lineEnd < 0) + { + line = null; + return false; + } + + line = text.Substring(0, lineEnd + 1); + buffer.Remove(0, lineEnd + 1); + return true; + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/integration/AllyIntegrationTestLogger.cs b/TBFTests/Rig/RegisterReaders/AllyReader/integration/AllyIntegrationTestLogger.cs new file mode 100644 index 000000000..7afb0aa6f --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/integration/AllyIntegrationTestLogger.cs @@ -0,0 +1,92 @@ +using System; +using System.Globalization; +using System.IO; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.RegisterReaders.AllyReader; + +namespace TBFTests.Rig.RegisterReaders.AllyReader.Integration +{ + internal sealed class AllyIntegrationTestLogger : IDisposable + { + private readonly TestContext context; + private readonly StreamWriter writer; + + public AllyIntegrationTestLogger(TestContext context) + { + this.context = context; + string directory = string.IsNullOrWhiteSpace(context.ResultsDirectory) ? Path.Combine(Path.GetTempPath(), "TBFTests", "AllyIntegration") : context.ResultsDirectory; + Directory.CreateDirectory(directory); + + string testName = SanitizeFileName(context.TestName ?? "AllyIntegration"); + string path = Path.Combine( directory, testName + "_" + DateTime.Now.ToString("yyyyMMdd_HHmmss_fff", CultureInfo.InvariantCulture) + ".log"); + writer = new StreamWriter(path, false) { AutoFlush = true }; + Log("Log file: " + path); + } + + public void Log(string message) + { + string line = string.Format( CultureInfo.InvariantCulture, "{0:O} | {1}", DateTime.Now, message); + context.WriteLine(line); + Console.WriteLine(line); + writer.WriteLine(line); + } + + public T Step(string name, Func action, Func formatResult = null) + { + Log("STEP START | " + name); + try + { + T result = action(); + string response = formatResult == null + ? Convert.ToString(result, CultureInfo.InvariantCulture) + : formatResult(result); + Log("STEP PASS | " + name + " | meter response: " + Escape(response)); + return result; + } + catch (Exception ex) + { + Log("STEP FAIL | " + name + " | " + ex.GetType().Name + ": " + ex.Message); + throw; + } + } + + public void Step(string name, Action action) + { + Step( name, () => { action(); return "ACK"; }); + } + + public void OpticalParse(string rawLine, bool parsed, AllyOpticalSample sample) + { + Log("OPTO RAW | " + Escape(rawLine)); + if (!parsed || sample == null) + { + Log("OPTO PARSE | FAIL"); + return; + } + + Log(string.Format( CultureInfo.InvariantCulture, "OPTO PARSE | PASS | flow={0}, volume=0x{1:X6} ({1}), timestamp=0x{2:X8} ({2})", + sample.RawFlow, + sample.RawVolume, + sample.RawTimestamp)); + } + + public void Dispose() + { + writer.Dispose(); + } + + private static string Escape(string value) + { + if (value == null) + return ""; + return value.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t"); + } + + private static string SanitizeFileName(string value) + { + foreach (char invalid in Path.GetInvalidFileNameChars()) + value = value.Replace(invalid, '_'); + return value; + } + } +} diff --git a/TBFTests/Rig/TestMethods/AllyCalibration/TestMethodConfigTest.cs b/TBFTests/Rig/TestMethods/AllyCalibration/TestMethodConfigTest.cs new file mode 100644 index 000000000..2124bcdca --- /dev/null +++ b/TBFTests/Rig/TestMethods/AllyCalibration/TestMethodConfigTest.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using JetBrains.Annotations; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.TestMethods.AllyCalibration; + +namespace TBFTests.Rig.TestMethods.AllyCalibration +{ + [TestClass] + [TestSubject(typeof(TestMethodCfg))] + public class TestMethodConfigTest + { + [TestMethod] + public void DefaultConfig_UsesUi2093CommunicationDefaults() + { + TestMethodCfg config = new TestMethodCfg( + new TBF.Rig.TestMethods.AllyCalibration.Factory()); + + Assert.AreEqual(5000, config.CommandTimeoutMs); + Assert.AreEqual(4, config.MaxAttempts); + Assert.AreEqual(250, config.DelayBetweenAttemptsMs); + Assert.AreEqual("SWM003", config.ExpectedDeviceType); + Assert.AreEqual(string.Empty, config.ExpectedFirmwareVersion); + } + + [TestMethod] + public void TestMethodParams_ExposeOnlyAllyActivitiesAndValidateFactor() + { + TestMethodParams parameters = new TestMethodParams(true); + ICollection activities = parameters.ParamValues(0); + + Assert.AreEqual(19, activities.Count); + CollectionAssert.Contains((System.Collections.ICollection)activities, "Read serial number"); + CollectionAssert.Contains((System.Collections.ICollection)activities, "Start offset learning"); + CollectionAssert.DoesNotContain((System.Collections.ICollection)activities, "Q2 correction"); + + string message; + Assert.IsTrue(parameters.ValidateParam(1, "100", out message)); + Assert.IsFalse(parameters.ValidateParam(1, "0", out message)); + Assert.IsFalse(parameters.ValidateParam(1, "1600", out message)); + } + } +} diff --git a/TBFTests/TBFTests.csproj b/TBFTests/TBFTests.csproj index 80b871637..1fcb52d3c 100644 --- a/TBFTests/TBFTests.csproj +++ b/TBFTests/TBFTests.csproj @@ -136,6 +136,22 @@ + + + + + + + + + + + + + + + + @@ -203,4 +219,4 @@ --> - \ No newline at end of file +