diff --git a/TBF/Rig/DataEntry/Uni/CycleBgEnForm.cs b/TBF/Rig/DataEntry/Uni/CycleBgEnForm.cs index e665a728b..ba259f94b 100644 --- a/TBF/Rig/DataEntry/Uni/CycleBgEnForm.cs +++ b/TBF/Rig/DataEntry/Uni/CycleBgEnForm.cs @@ -835,7 +835,7 @@ namespace TBF.Rig.DataEntry.Uni }; IRegReader[] selectedRegReaders = RegisterReaderSelection.GetSelectedForDataEntry( - regReaders, log, "serial number"); + regReaders, WaterMeters, log, "serial number"); BeforeUpdate(selectedRegReaders); this.DoneUpdateBySerial += (s, eArgs) => { diff --git a/TBF/Rig/DataEntry/Uni/DataEntryWatermark.cs b/TBF/Rig/DataEntry/Uni/DataEntryWatermark.cs new file mode 100644 index 000000000..50400605b --- /dev/null +++ b/TBF/Rig/DataEntry/Uni/DataEntryWatermark.cs @@ -0,0 +1,38 @@ +using System; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace TBF.Rig.DataEntry.Uni +{ + /// + /// Displays status text without changing the editable value of a control. + /// + internal static class DataEntryWatermark + { + private const int EmSetCueBanner = 0x1501; + private const int CbSetCueBanner = 0x1703; + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr SendMessage( + IntPtr hWnd, + int message, + IntPtr wParam, + [MarshalAs(UnmanagedType.LPWStr)] string lParam); + + public static void Set(Control control, string text) + { + if (control == null || control.IsDisposed) + return; + + int message; + if (control is TextBox) + message = EmSetCueBanner; + else if (control is ComboBox) + message = CbSetCueBanner; + else + return; + + SendMessage(control.Handle, message, new IntPtr(1), text ?? string.Empty); + } + } +} diff --git a/TBF/Rig/DataEntry/Uni/RegisterReaderSelection.cs b/TBF/Rig/DataEntry/Uni/RegisterReaderSelection.cs new file mode 100644 index 000000000..6099cc574 --- /dev/null +++ b/TBF/Rig/DataEntry/Uni/RegisterReaderSelection.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using log4net; +using Results.Entities; +using TBF.Rig.GenericDevices; + +namespace TBF.Rig.DataEntry.Uni +{ + /// + /// Uses the smart-meter selection for readers that support automatic Data + /// Entry and preserves the prechecked water-meter state for other readers. + /// + internal static class RegisterReaderSelection + { + public static IRegReader[] GetSelectedForDataEntry( + IEnumerable readers, + IList waterMeters, + ILog log, + string operation) + { + IRegReader[] allSlots = readers == null + ? new IRegReader[0] + : readers.ToArray(); + long enabledHeads = Program.LocalSettings.OptoHeadsEnabled; + + IRegReader[] selected = allSlots + .Where(reader => IsSelectedForDataEntry(reader, waterMeters, enabledHeads)) + .ToArray(); + + string selectedPositions = string.Join(",", selected.Select(reader => reader.Position)); + string skippedPositions = string.Join(",", allSlots + .Where(reader => reader != null && + !IsSelectedForDataEntry(reader, waterMeters, enabledHeads)) + .Select(reader => reader.Position)); + + log.DebugFormat( + "DATA_ENTRY_READER_FILTER Operation={0}, OptoHeadsEnabled=0x{1:X12}, " + + "Slots={2}, Readers={3}, Selected={4}, SelectedPositions=[{5}], " + + "SkippedPositions=[{6}], SelectionRule=SmartMeterMaskOrPrecheckedWaterMeter", + operation, + enabledHeads, + allSlots.Length, + allSlots.Count(reader => reader != null), + selected.Length, + selectedPositions, + skippedPositions); + + foreach (IRegReader reader in allSlots.Where(item => item != null)) + { + log.DebugFormat( + "DATA_ENTRY_READER_SLOT Operation={0}, Name={1}, Type={2}, Position={3}, " + + "DebugLevel={4}, Selected={5}{6}", + operation, + reader.Name, + reader.GetType().Name, + reader.Position, + reader.DebugLevel, + IsSelectedForDataEntry(reader, waterMeters, enabledHeads), + GetSelectionDetails(reader, waterMeters, enabledHeads)); + } + + return selected; + } + + internal static bool IsSelectedForDataEntry( + IRegReader reader, + IList waterMeters, + long enabledHeads) + { + if (reader == null) + return false; + +#if IPERL + var iperlHead = reader as + TBF.Rig.TestMethods.iPerlCommunication.iPerlHead.IperlHead; + if (iperlHead != null) + { + int position0 = iperlHead.Position - 1; + if (position0 < 0 || position0 >= 63) + return false; + + return !iperlHead.Disabled && + (enabledHeads & (1L << position0)) != 0; + } +#endif + + if (reader is ISmartMeterReader) + { + int position0 = reader.Position - 1; + return position0 >= 0 && position0 < 63 && + (enabledHeads & (1L << position0)) != 0; + } + + // Backward compatibility: readers outside the smart-meter family keep the + // selection made in the cycle-begin form instead of using the smart mask. + return IsPrecheckedWaterMeter(reader, waterMeters); + } + + private static bool IsPrecheckedWaterMeter( + IRegReader reader, + IList waterMeters) + { + if (reader == null) + return false; + + int meterIndex = reader.Position - 1; + if (waterMeters == null || meterIndex < 0 || meterIndex >= waterMeters.Count) + return true; + + WaterMeter waterMeter = waterMeters[meterIndex]; + return waterMeter != null && !waterMeter.Disabled; + } + + private static string GetSelectionDetails( + IRegReader reader, + IList waterMeters, + long enabledHeads) + { +#if IPERL + var iperlHead = reader as + TBF.Rig.TestMethods.iPerlCommunication.iPerlHead.IperlHead; + if (iperlHead != null) + { + int position0 = iperlHead.Position - 1; + bool maskBit = position0 >= 0 && position0 < 63 && + (enabledHeads & (1L << position0)) != 0; + return string.Format( + ", IPerlDisabled={0}, SelectionMaskBit={1}, CommFailed={2}, " + + "SerialCached={3}", + iperlHead.Disabled, + maskBit, + iperlHead.CommFailed, + !string.IsNullOrEmpty(iperlHead.SerialNr)); + } +#endif + if (reader is ISmartMeterReader) + { + int position0 = reader.Position - 1; + bool maskBit = position0 >= 0 && position0 < 63 && + (enabledHeads & (1L << position0)) != 0; + return string.Format( + ", SelectionSource=SmartMeterMask, SelectionMaskBit={0}", + maskBit); + } + + return string.Format( + ", SelectionSource=PrecheckedWaterMeter, Prechecked={0}", + IsPrecheckedWaterMeter(reader, waterMeters)); + } + } +} diff --git a/TBF/Rig/DataEntry/Uni/TestStartEndForm.cs b/TBF/Rig/DataEntry/Uni/TestStartEndForm.cs index bde2fc778..2fa28d200 100644 --- a/TBF/Rig/DataEntry/Uni/TestStartEndForm.cs +++ b/TBF/Rig/DataEntry/Uni/TestStartEndForm.cs @@ -586,7 +586,7 @@ namespace TBF.Rig.DataEntry.Uni if (bAutoRead) { IRegReader[] selectedRegReaders = RegisterReaderSelection.GetSelectedForDataEntry( - regReaders, log, isEnd ? "end volume" : "begin volume"); + regReaders, waterMeters, log, isEnd ? "end volume" : "begin volume"); log.Debug($"Reading serial numbers from register readers... IsEnd: {isEnd}"); this.VolumeStartReadbyRegReader += (s, eArgs) => { diff --git a/TBF/Rig/GenericDevices/IRegReaderSmart.cs b/TBF/Rig/GenericDevices/IRegReaderSmart.cs index 5133990d5..561546eb1 100644 --- a/TBF/Rig/GenericDevices/IRegReaderSmart.cs +++ b/TBF/Rig/GenericDevices/IRegReaderSmart.cs @@ -3,7 +3,7 @@ using System.Threading.Tasks; namespace TBF.Rig.GenericDevices { - public interface IRegReaderSmart + public interface IRegReaderSmart : ISmartMeterReader { Task DataEntry_ReadSerialNumber(); Task DataEntry_ReadBeginVolume(); @@ -13,4 +13,4 @@ namespace TBF.Rig.GenericDevices int MuxBoardNrOrGroup14 { get; } } -} \ No newline at end of file +} diff --git a/TBF/Rig/GenericDevices/ISmartMeterReader.cs b/TBF/Rig/GenericDevices/ISmartMeterReader.cs new file mode 100644 index 000000000..c100be7e0 --- /dev/null +++ b/TBF/Rig/GenericDevices/ISmartMeterReader.cs @@ -0,0 +1,10 @@ +namespace TBF.Rig.GenericDevices +{ + /// + /// Identifies register readers that belong to the smart-meter family and + /// therefore use the shared smart-meter position selection. + /// + public interface ISmartMeterReader + { + } +} diff --git a/TBF/Rig/RegisterReaders/AllyReader/AllyMeterReader.cs b/TBF/Rig/RegisterReaders/AllyReader/AllyMeterReader.cs index 43cb37b7a..ef376e5df 100644 --- a/TBF/Rig/RegisterReaders/AllyReader/AllyMeterReader.cs +++ b/TBF/Rig/RegisterReaders/AllyReader/AllyMeterReader.cs @@ -1,13 +1,17 @@ using System; using System.Collections.Generic; +using System.IO; using System.IO.Ports; using System.Text; +using System.Threading; +using System.Threading.Tasks; using Common; using Config.Entities; using log4net; using TBF.Rig.Generic; using TBF.Rig.GenericDevices; using TBF.Rig.RegisterReaders.AllyReader.Communication; +using TBF.Rig.Uni.SharedDialogs.SmartMetersCommunication.common; namespace TBF.Rig.RegisterReaders.AllyReader { @@ -15,8 +19,12 @@ namespace TBF.Rig.RegisterReaders.AllyReader IDevice, IRegReaderDatastream, ISessionDataMngmnt, - IOperation + IOperation, + IRegReaderSmart, + ISmartReader { + private const int DataEntryCommandTimeoutMs = 5000; + private const int DataEntryOpticalTimeoutMs = 10000; private const int MaxStoredSamples = 40000; private const long RawVolumeModulo = 0x1000000L; private const long RawVolumeHalfRange = RawVolumeModulo / 2; @@ -69,7 +77,17 @@ namespace TBF.Rig.RegisterReaders.AllyReader 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 bool CommFailed { get; set; } + public bool Disabled { get; set; } + + public string SerialNr + { + get { return SerialNumber ?? string.Empty; } + set { SerialNumber = value ?? string.Empty; } + } + + public string CommInterface { get { return "Touch-Read"; } } + public int RfidComPortNr { get { return allyCfg == null ? 0 : allyCfg.CommandComPortNr; } } public AllyMeterSize ConfiguredMeterSize { @@ -102,12 +120,17 @@ namespace TBF.Rig.RegisterReaders.AllyReader public void RunDeviceBefore() { - if (!streamEnabled || opticalPort == null || !opticalPort.IsOpen) - return; - try { - string text = opticalPort.ReadExisting(); + string text; + lock (opticalSync) + { + if (!streamEnabled || opticalPort == null || !opticalPort.IsOpen) + return; + + text = opticalPort.ReadExisting(); + } + if (!string.IsNullOrEmpty(text)) ProcessOpticalText(text); } @@ -266,6 +289,36 @@ namespace TBF.Rig.RegisterReaders.AllyReader return ExecuteCommand(service => SerialNumber = service.ReadSerialNumber(timeoutMs)); } + public Task DataEntry_ReadSerialNumber() + { + if (!string.IsNullOrEmpty(SerialNumber)) + return Task.FromResult(SerialNumber); + + return Task.Run(() => + { + try + { + return ReadSerialNumber(DataEntryCommandTimeoutMs); + } + catch (Exception ex) + { + CommFailed = true; + log.ErrorFormat("ALLY Data Entry serial-number read failed: {0}", ex.Message); + return null; + } + }); + } + + public Task DataEntry_ReadBeginVolume() + { + return ReadDataEntryVolume(true); + } + + public Task DataEntry_ReadEndVolume() + { + return ReadDataEntryVolume(false); + } + public AllyVersionInfo ReadVersionAndType(int timeoutMs) { if (DebugLevel != DebugMode.Normal) @@ -397,6 +450,11 @@ namespace TBF.Rig.RegisterReaders.AllyReader public RegisterReaderType RegisterReaderType { get { return RegisterReaderType.DataStream; } } + // ALLY heads have independent ports. Separate subgroups keep Data Entry reads + // deterministic while retaining the common smart-reader scheduling contract. + public int Group { get { return 1; } } + public int MuxBoardNrOrGroup14 { get { return Position; } } + public int Position { get @@ -427,14 +485,134 @@ namespace TBF.Rig.RegisterReaders.AllyReader } } public double WMVolume { get { return Math.Abs(EndWMState - BeginWMState); } } - public double BeginWMState { get { return beginWMState; } } - public double EndWMState { get { return endWMState; } } + public double BeginWMState + { + get { return beginWMState; } + set { beginWMState = value; } + } + public double EndWMState + { + get { return endWMState; } + set { endWMState = value; } + } 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; } } + public void ResetNfcInterface(bool? nfc_on = null) + { + log.DebugFormat( + "ALLY {0}: ResetNfcInterface({1}) ignored; command interface is fixed to Touch-Read.", + Name, + nfc_on.HasValue ? nfc_on.Value.ToString() : "null"); + } + + public void SetNfcInterface() + { + log.DebugFormat( + "ALLY {0}: SetNfcInterface ignored; command interface is fixed to Touch-Read.", + Name); + } + + public void SetRfidInterface() + { + log.DebugFormat( + "ALLY {0}: SetRfidInterface ignored; command interface is fixed to Touch-Read.", + Name); + } + + public void SetCommunicationInterface(string commInterface) + { + if (!string.IsNullOrWhiteSpace(commInterface) && + !string.Equals(commInterface, CommInterface, StringComparison.OrdinalIgnoreCase)) + { + log.WarnFormat( + "ALLY {0}: communication interface '{1}' is not supported; using {2}.", + Name, + commInterface, + CommInterface); + } + } + + public void WriteBinary(BinaryWriter writer) + { + if (writer == null) + throw new ArgumentNullException("writer"); + + writer.Write(Disabled); + writer.Write(CommFailed); + writer.Write(SerialNr); + writer.Write(beginWMState); + writer.Write(endWMState); + } + + public void ReadBinary(BinaryReader reader) + { + if (reader == null) + throw new ArgumentNullException("reader"); + + Disabled = reader.ReadBoolean(); + CommFailed = reader.ReadBoolean(); + SerialNr = reader.ReadString(); + beginWMState = reader.ReadDouble(); + endWMState = reader.ReadDouble(); + } + + private Task ReadDataEntryVolume(bool isBegin) + { + return Task.Run(() => + { + if (DebugLevel != DebugMode.Normal) + { + return Double.NaN; + } + + try + { + Start(); + DateTime deadline = DateTime.UtcNow.AddMilliseconds(DataEntryOpticalTimeoutMs); + while (DateTime.UtcNow < deadline) + { + RunDeviceBefore(); + lock (opticalSync) + { + if (hasTestStartSample) + { + double volume = isBegin ? beginWMState : endWMState; + log.DebugFormat( + "ALLY Data Entry {0} volume read: {1} l", + isBegin ? "begin" : "end", volume); + return volume; + } + } + + Thread.Sleep(20); + } + + log.WarnFormat( + "ALLY Data Entry {0} volume timeout after {1} ms on COM{2}", + isBegin ? "begin" : "end", + DataEntryOpticalTimeoutMs, + allyCfg.OptoComPortNr); + return Double.NaN; + } + catch (Exception ex) + { + CommFailed = true; + log.ErrorFormat( + "ALLY Data Entry {0} volume read failed: {1}", + isBegin ? "begin" : "end", ex.Message); + return Double.NaN; + } + finally + { + Stop(); + } + }); + } + private void ProcessOpticalText(string text) { lock (opticalSync) diff --git a/TBF/Rig/RegisterReaders/IPerlReader/implementations/SmartReader.cs b/TBF/Rig/RegisterReaders/IPerlReader/implementations/SmartReader.cs index f7b05fcf7..c2bdedf16 100644 --- a/TBF/Rig/RegisterReaders/IPerlReader/implementations/SmartReader.cs +++ b/TBF/Rig/RegisterReaders/IPerlReader/implementations/SmartReader.cs @@ -25,7 +25,7 @@ namespace TBF.Rig.RegisterReaders.IPerlReader.implementations /// /// based on IPerlReader class /// - public class SmartReader : ComponentBase, IDevice, IRegReaderDatastream, ISessionDataMngmnt, IOperation, ISmartReader + public class SmartReader : ComponentBase, IDevice, IRegReaderDatastream, ISessionDataMngmnt, IOperation, ISmartReader, ISmartMeterReader { private static readonly ILog log = LogManager.GetLogger(typeof(SmartReader)); @@ -1667,4 +1667,4 @@ namespace TBF.Rig.RegisterReaders.IPerlReader.implementations } } -} \ No newline at end of file +} diff --git a/TBF/Rig/RegisterReaders/iPerlASICReader/implementations/SmartReader.cs b/TBF/Rig/RegisterReaders/iPerlASICReader/implementations/SmartReader.cs index bbeb66220..18ea25b47 100644 --- a/TBF/Rig/RegisterReaders/iPerlASICReader/implementations/SmartReader.cs +++ b/TBF/Rig/RegisterReaders/iPerlASICReader/implementations/SmartReader.cs @@ -25,7 +25,7 @@ namespace TBF.Rig.RegisterReaders.iPerlASICReader.implementations /// /// based on IPerlReader class /// - public class SmartReader : ComponentBase, IDevice, IRegReaderDatastream, ISessionDataMngmnt, IOperation, ISmartReader + public class SmartReader : ComponentBase, IDevice, IRegReaderDatastream, ISessionDataMngmnt, IOperation, ISmartReader, ISmartMeterReader { private static readonly ILog log = LogManager.GetLogger(typeof(SmartReader)); @@ -1668,4 +1668,4 @@ namespace TBF.Rig.RegisterReaders.iPerlASICReader.implementations } } -} \ No newline at end of file +} diff --git a/TBF/TBF.csproj b/TBF/TBF.csproj index 4a1bfa056..771283bd9 100644 --- a/TBF/TBF.csproj +++ b/TBF/TBF.csproj @@ -802,6 +802,7 @@ + diff --git a/TBFTests/Rig/DataEntry/Uni/RegisterReaderSelectionTests.cs b/TBFTests/Rig/DataEntry/Uni/RegisterReaderSelectionTests.cs new file mode 100644 index 000000000..7f272bcec --- /dev/null +++ b/TBFTests/Rig/DataEntry/Uni/RegisterReaderSelectionTests.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Results.Entities; +using TBF.Rig.DataEntry.Uni; +using TBF.Rig.GenericDevices; + +namespace TBFTests.Rig.DataEntry.Uni +{ + [TestClass] + public class RegisterReaderSelectionTests + { + [TestMethod] + public void NonSmartReader_PrecheckedMeter_IsSelectedWithoutSmartMask() + { + var reader = CreateReader(1); + var waterMeters = new List + { + new WaterMeter { Disabled = false } + }; + + bool selected = RegisterReaderSelection.IsSelectedForDataEntry( + reader.Object, waterMeters, 0L); + + Assert.IsTrue(selected); + } + + [TestMethod] + public void NonSmartReader_UncheckedMeter_IsNotSelected() + { + var reader = CreateReader(2); + var waterMeters = new List + { + new WaterMeter { Disabled = false }, + new WaterMeter { Disabled = true } + }; + + bool selected = RegisterReaderSelection.IsSelectedForDataEntry( + reader.Object, waterMeters, long.MaxValue); + + Assert.IsFalse(selected); + } + + [TestMethod] + public void NonSmartReader_WithoutWaterMeterContext_PreservesLegacySelection() + { + var reader = CreateReader(3); + + bool selected = RegisterReaderSelection.IsSelectedForDataEntry( + reader.Object, null, 0L); + + Assert.IsTrue(selected); + } + + [TestMethod] + public void SmartReader_SelectedMaskBit_OverridesUncheckedWaterMeter() + { + var reader = CreateReader(2, true); + var waterMeters = new List + { + new WaterMeter { Disabled = false }, + new WaterMeter { Disabled = true } + }; + + bool selected = RegisterReaderSelection.IsSelectedForDataEntry( + reader.Object, waterMeters, 1L << 1); + + Assert.IsTrue(selected); + } + + [TestMethod] + public void SmartReader_UnselectedMaskBit_IgnoresPrecheckedWaterMeter() + { + var reader = CreateReader(1, true); + var waterMeters = new List + { + new WaterMeter { Disabled = false } + }; + + bool selected = RegisterReaderSelection.IsSelectedForDataEntry( + reader.Object, waterMeters, 0L); + + Assert.IsFalse(selected); + } + + private static Mock CreateReader(int position, bool smart = false) + { + var reader = new Mock(); + if (smart) + reader.As(); + reader.SetupGet(item => item.Position).Returns(position); + return reader; + } + } +} diff --git a/TBFTests/Rig/DataEntry/Uni/SmartMeterReaderFamilyTests.cs b/TBFTests/Rig/DataEntry/Uni/SmartMeterReaderFamilyTests.cs new file mode 100644 index 000000000..555cd66de --- /dev/null +++ b/TBFTests/Rig/DataEntry/Uni/SmartMeterReaderFamilyTests.cs @@ -0,0 +1,34 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.GenericDevices; +using TBF.Rig.RegisterReaders.AllyReader; +using TBF.Rig.RegisterReaders.GenesisRegReader.implementations; +using TBF.Rig.TestMethods.iPerlCommunication.iPerlHead; +using OldIPerlReader = TBF.Rig.RegisterReaders.IPerlReader.implementations.SmartReader; +using IPerlAsicReader = TBF.Rig.RegisterReaders.iPerlASICReader.implementations.SmartReader; +using PoseidonReader = TBF.Rig.RegisterReaders.PoseidonReader.implementations.PoseidonReader; + +namespace TBFTests.Rig.DataEntry.Uni +{ + [TestClass] + public class SmartMeterReaderFamilyTests + { + [TestMethod] + public void SmartMeterFamily_ContainsSupportedReadersOnly() + { + AssertSmartMeter(typeof(IperlHead)); + AssertSmartMeter(typeof(GenesisSmartReader)); + AssertSmartMeter(typeof(AllyMeterReader)); + AssertSmartMeter(typeof(OldIPerlReader)); + AssertSmartMeter(typeof(IPerlAsicReader)); + + Assert.IsFalse(typeof(ISmartMeterReader).IsAssignableFrom(typeof(PoseidonReader))); + } + + private static void AssertSmartMeter(System.Type readerType) + { + Assert.IsTrue( + typeof(ISmartMeterReader).IsAssignableFrom(readerType), + readerType.FullName + " must belong to the smart-meter family."); + } + } +} diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/AllyMeterReaderTest.cs b/TBFTests/Rig/RegisterReaders/AllyReader/AllyMeterReaderTest.cs index d15d74298..3a895e0b7 100644 --- a/TBFTests/Rig/RegisterReaders/AllyReader/AllyMeterReaderTest.cs +++ b/TBFTests/Rig/RegisterReaders/AllyReader/AllyMeterReaderTest.cs @@ -1,9 +1,12 @@ using System; +using System.IO; using System.Reflection; using Common; using JetBrains.Annotations; using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.GenericDevices; using TBF.Rig.RegisterReaders.AllyReader; +using TBF.Rig.Uni.SharedDialogs.SmartMetersCommunication.common; namespace TBFTests.Rig.RegisterReaders.AllyReader { @@ -11,6 +14,63 @@ namespace TBFTests.Rig.RegisterReaders.AllyReader [TestSubject(typeof(AllyMeterReader))] public class AllyMeterReaderTest { + [TestMethod] + public void Reader_ImplementsSmartDataEntryContract() + { + AllyMeterReader reader = CreateReader(AllyMeterSize.FiveEighths); + + Assert.IsInstanceOfType(reader, typeof(IRegReaderSmart)); + Assert.IsInstanceOfType(reader, typeof(ISmartMeterReader)); + Assert.IsInstanceOfType(reader, typeof(ISmartReader)); + } + + [TestMethod] + public void SmartReaderContract_MapsAllyCommandInterface() + { + ISmartReader reader = CreateReader(AllyMeterSize.FiveEighths); + + Assert.AreEqual("Touch-Read", reader.CommInterface); + Assert.AreEqual(1, reader.RfidComPortNr); + + reader.ResetNfcInterface(); + reader.SetNfcInterface(); + reader.SetRfidInterface(); + reader.SetCommunicationInterface("RFID"); + } + + [TestMethod] + public void SmartReaderContract_WriteReadBinary_RestoresDataEntryState() + { + ISmartReader source = CreateReader(AllyMeterSize.FiveEighths); + source.Disabled = true; + source.CommFailed = true; + source.SerialNr = "ALLY-123456"; + source.BeginWMState = 12.5D; + source.EndWMState = 15.75D; + + byte[] data; + using (MemoryStream stream = new MemoryStream()) + using (BinaryWriter writer = new BinaryWriter(stream)) + { + source.WriteBinary(writer); + writer.Flush(); + data = stream.ToArray(); + } + + ISmartReader restored = CreateReader(AllyMeterSize.FiveEighths); + using (MemoryStream stream = new MemoryStream(data)) + using (BinaryReader reader = new BinaryReader(stream)) + { + restored.ReadBinary(reader); + } + + Assert.IsTrue(restored.Disabled); + Assert.IsTrue(restored.CommFailed); + Assert.AreEqual("ALLY-123456", restored.SerialNr); + Assert.AreEqual(12.5D, restored.BeginWMState); + Assert.AreEqual(15.75D, restored.EndWMState); + } + [TestMethod] public void GetResetCalibrationFactorPercent_AllSupportedSizes_ReturnsUi2093Value() { diff --git a/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyIntegrationTests.cs b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyIntegrationTests.cs new file mode 100644 index 000000000..f4a3eeddd --- /dev/null +++ b/TBFTests/Rig/RegisterReaders/AllyReader/communication/AllyIntegrationTests.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Ports; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using TBF.Rig.RegisterReaders.AllyReader; +using TBF.Rig.RegisterReaders.AllyReader.Communication; + +namespace TBFTests.Rig.RegisterReaders.AllyReader.communication +{ + /// + /// Direct hardware smoke test modelled after IperlHatIntegrationTests. + /// COM3 is the ALLY Touch-Read connection and COM4 is the optical source. + /// + [TestClass] + [DoNotParallelize] + public class AllyIntegrationTests + { + private const string CommandComPort = "COM3"; + private const string OpticalComPort = "COM4"; + private const int CommandBaudRate = 2400; + private const int OpticalBaudRate = 9600; + private const int ReadTimeoutMs = 5000; + private const int OpticalReadSeconds = 10; + private const byte ActiveMeterMode = 0x02; + private const byte InitialMeterMode = 0x09; + + [TestMethod] + [TestCategory("Hardware")] + [TestCategory("Serial")] + public void Serial_ReadFactoryId_SetActive_ReadOptical_SetInitial() + { + Console.WriteLine( + "ALLY integration test: command={0}/{1}, optical={2}/{3}", + CommandComPort, + CommandBaudRate, + OpticalComPort, + OpticalBaudRate); + + using (var commandPort = new SerialPort( + CommandComPort, + CommandBaudRate, + Parity.None, + 8, + StopBits.One)) + { + commandPort.Handshake = Handshake.None; + commandPort.ReadTimeout = ReadTimeoutMs; + commandPort.WriteTimeout = ReadTimeoutMs; + + Console.WriteLine("OPEN command port " + CommandComPort); + commandPort.Open(); + bool activeModeCommandAttempted = false; + Exception testFailure = null; + + try + { + byte[] factoryIdRequest = new AllyFrameBuilder() + .WithCommand(AllyCommand.ViewFactoryId) + .BuildBytes(); + AllyResponse factoryIdResponse = SendRequest( + commandPort, + factoryIdRequest, + "ViewFactoryId"); + + string serialNumber = factoryIdResponse.GetNullTerminatedAscii(); + Console.WriteLine("PARSE PASS ViewFactoryId: serial number='{0}'", serialNumber); + Assert.IsFalse( + string.IsNullOrWhiteSpace(serialNumber), + "ViewFactoryId returned an empty manufacturing serial number."); + + byte[] activeModeRequest = new AllyFrameBuilder() + .WithCommand(AllyCommand.SetMeterMode) + .WithByte(ActiveMeterMode) + .BuildBytes(); + activeModeCommandAttempted = true; + SendRequest(commandPort, activeModeRequest, "SetMeterMode Active 0x02"); + Console.WriteLine("STEP PASS Active meter mode 0x02 is acknowledged."); + + int parsedSamples = ReadOpticalSamples(); + Assert.IsTrue( + parsedSamples > 0, + "No valid ALLY optical telegram was parsed on " + OpticalComPort + "."); + Console.WriteLine("STEP PASS Parsed optical samples: " + parsedSamples); + } + catch (Exception ex) + { + testFailure = ex; + Console.WriteLine("TEST FAIL: " + ex); + throw; + } + finally + { + // Attempt the safe state even when the active command timed out: the + // meter may have accepted it while its response was lost. + if (activeModeCommandAttempted && commandPort.IsOpen) + { + try + { + byte[] initialModeRequest = new AllyFrameBuilder() + .WithCommand(AllyCommand.SetMeterMode) + .WithByte(InitialMeterMode) + .BuildBytes(); + SendRequest(commandPort, initialModeRequest, "SetMeterMode Initial 0x09"); + Console.WriteLine("STEP PASS Initial meter mode 0x09 is acknowledged."); + } + catch (Exception restoreException) + { + Console.WriteLine("RESTORE FAIL Initial mode 0x09: " + restoreException); + if (testFailure == null) + throw; + } + } + + Console.WriteLine("CLOSE command port " + CommandComPort); + } + } + + Console.WriteLine("ALLY integration test completed."); + } + + private static AllyResponse SendRequest( + SerialPort port, + byte[] request, + string commandName) + { + port.DiscardInBuffer(); + Console.WriteLine(commandName + " TX -> " + ToHex(request)); + port.Write(request, 0, request.Length); + + byte[] response; + try + { + response = ReadResponse(port); + } + catch (Exception ex) + { + Console.WriteLine(commandName + " RX FAIL: " + ex.Message); + throw; + } + + Console.WriteLine(commandName + " RX <- " + ToHex(response)); + + AllyResponse parsed; + try + { + parsed = new AllyFrameParser().ParseResponse(response); + Console.WriteLine( + commandName + " PARSE PASS: status=0x" + + parsed.Status.ToString("X2") + + ", payload=" + ToHex(parsed.Payload)); + } + catch (Exception ex) + { + Console.WriteLine(commandName + " PARSE FAIL: " + ex.Message); + throw; + } + + Assert.IsTrue( + parsed.IsSuccess, + commandName + " returned status 0x" + parsed.Status.ToString("X2") + "."); + return parsed; + } + + private static byte[] ReadResponse(SerialPort port) + { + var response = new List(); + Stopwatch stopwatch = Stopwatch.StartNew(); + + int value; + do + { + value = ReadByte(port, stopwatch); + } + while (value != 0x53); + + response.Add((byte)value); + response.Add((byte)ReadByte(port, stopwatch)); + int length = ReadByte(port, stopwatch); + if (length < 5) + throw new FormatException("ALLY response length is less than five bytes."); + response.Add((byte)length); + + while (response.Count < length) + response.Add((byte)ReadByte(port, stopwatch)); + return response.ToArray(); + } + + private static int ReadByte(SerialPort port, Stopwatch stopwatch) + { + int remaining = ReadTimeoutMs - (int)stopwatch.ElapsedMilliseconds; + if (remaining <= 0) + throw new TimeoutException("ALLY response timeout."); + + port.ReadTimeout = remaining; + int value = port.ReadByte(); + if (value < 0) + throw new IOException("ALLY command port returned end of stream."); + return value; + } + + private static int ReadOpticalSamples() + { + int parsedSamples = 0; + using (var opticalPort = new SerialPort( + OpticalComPort, + OpticalBaudRate, + Parity.None, + 8, + StopBits.One)) + { + opticalPort.Handshake = Handshake.None; + opticalPort.ReadTimeout = 1000; + opticalPort.NewLine = "\r\n"; + opticalPort.Encoding = Encoding.ASCII; + + Console.WriteLine("OPEN optical port " + OpticalComPort); + opticalPort.Open(); + opticalPort.DiscardInBuffer(); + + DateTime end = DateTime.UtcNow.AddSeconds(OpticalReadSeconds); + while (DateTime.UtcNow < end) + { + try + { + string rawLine = opticalPort.ReadLine() + "\r\n"; + Console.WriteLine("OPTO RX <- " + ToHex(Encoding.ASCII.GetBytes(rawLine))); + Console.WriteLine("OPTO TEXT <- " + Escape(rawLine)); + + AllyOpticalSample sample; + if (!AllyOpticalSample.TryParse(rawLine, DateTime.UtcNow, out sample)) + { + Console.WriteLine("OPTO PARSE FAIL"); + continue; + } + + parsedSamples++; + Console.WriteLine( + "OPTO PARSE PASS: sample={0}, flow={1}, rawVolume={2}, rawTimestamp={3}", + parsedSamples, + sample.RawFlow, + sample.RawVolume, + sample.RawTimestamp); + } + catch (TimeoutException) + { + Console.WriteLine("OPTO WAIT: no complete line received in the last second."); + } + } + + Console.WriteLine("CLOSE optical port " + OpticalComPort); + } + + return parsedSamples; + } + + private static string ToHex(byte[] value) + { + return value == null || value.Length == 0 + ? "" + : BitConverter.ToString(value).Replace('-', ' '); + } + + private static string Escape(string value) + { + return value.Replace("\r", "\\r").Replace("\n", "\\n").Replace("\t", "\\t"); + } + } +} diff --git a/TBFTests/TBFTests.csproj b/TBFTests/TBFTests.csproj index 1fcb52d3c..cbefac8ed 100644 --- a/TBFTests/TBFTests.csproj +++ b/TBFTests/TBFTests.csproj @@ -102,6 +102,8 @@ + +