diff --git a/src/Daqifi.Core.Tests/Device/Internal/StatusChannelPopulatorTests.cs b/src/Daqifi.Core.Tests/Device/Internal/StatusChannelPopulatorTests.cs new file mode 100644 index 0000000..112ca13 --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Internal/StatusChannelPopulatorTests.cs @@ -0,0 +1,249 @@ +using Daqifi.Core.Channel; +using Daqifi.Core.Communication.Messages; +using Daqifi.Core.Device.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Daqifi.Core.Tests.Device.Internal; + +/// +/// Unit tests for , the status-frame-to-channel mapping +/// extracted from DaqifiDevice. +/// +/// +/// The mapping's behaviour through the device is already covered by +/// ChannelPopulationTests; these exercise the collaborator directly, which is what the +/// extraction newly makes possible — no device, no connection, no channels lock — and pin the +/// contract of the seam the device now depends on. +/// +public class StatusChannelPopulatorTests +{ + private static StatusChannelPopulator Create(ILogger? logger = null, string name = "Nq1") + => new(logger ?? NullLogger.Instance, () => name); + + [Fact] + public void Constructor_WithNullLogger_Throws() + { + Assert.Throws(() => new StatusChannelPopulator(null!, () => "Nq1")); + } + + [Fact] + public void Constructor_WithNullDeviceName_Throws() + { + Assert.Throws(() => new StatusChannelPopulator(NullLogger.Instance, null!)); + } + + [Fact] + public void Populate_AppendsAnalogThenDigital_AndReportsBothCounts() + { + var destination = new List(); + + var (analogCount, digitalCount) = Create().Populate( + new DaqifiOutMessage { AnalogInPortNum = 3, DigitalPortNum = 2 }, + Array.Empty(), + destination); + + Assert.Equal(3, analogCount); + Assert.Equal(2, digitalCount); + Assert.Equal( + new[] { ChannelType.Analog, ChannelType.Analog, ChannelType.Analog, ChannelType.Digital, ChannelType.Digital }, + destination.Select(c => c.Type)); + Assert.Equal(new[] { "AI0", "AI1", "AI2", "DIO0", "DIO1" }, destination.Select(c => c.Name)); + } + + [Fact] + public void Populate_WithNoPortsReported_AppendsNothingAndReportsZero() + { + var destination = new List(); + + var (analogCount, digitalCount) = Create().Populate( + new DaqifiOutMessage(), Array.Empty(), destination); + + Assert.Equal(0, analogCount); + Assert.Equal(0, digitalCount); + Assert.Empty(destination); + } + + [Fact] + public void Populate_ReusesExistingChannelInstances_WhenIdentityIsUnchanged() + { + var existingAnalog = new AnalogChannel(0); + var existingDigital = new DigitalChannel(0, isPwmCapable: true) { Direction = ChannelDirection.Output }; + var destination = new List(); + + Create().Populate( + new DaqifiOutMessage { AnalogInPortNum = 1, DigitalPortNum = 1 }, + new IChannel[] { existingAnalog, existingDigital }, + destination); + + // Same references back, so consumer-held channels and the configuration on them survive. + Assert.Same(existingAnalog, destination[0]); + Assert.Same(existingDigital, destination[1]); + Assert.Equal(ChannelDirection.Output, destination[1].Direction); + } + + [Fact] + public void Populate_ResyncsAnalogIsEnabled_FromReportedMask() + { + // Channel 0 was enabled in Core's view; the device reports only channel 1 enabled. + var existing = new AnalogChannel(0) { IsEnabled = true }; + var message = new DaqifiOutMessage { AnalogInPortNum = 2 }; + message.AnalogInPortEnabled = Google.Protobuf.ByteString.CopyFrom(new byte[] { 0b0000_0010 }); + var destination = new List(); + + Create().Populate(message, new IChannel[] { existing }, destination); + + Assert.False(destination[0].IsEnabled); + Assert.True(destination[1].IsEnabled); + } + + [Fact] + public void Populate_LeavesIsEnabledAlone_WhenNoMaskIsReported() + { + // An empty mask is ambiguous between "nothing enabled" and "not reported" on older + // firmware, so it must not be treated as the source of truth. + var existing = new AnalogChannel(0) { IsEnabled = true }; + var destination = new List(); + + Create().Populate(new DaqifiOutMessage { AnalogInPortNum = 1 }, new IChannel[] { existing }, destination); + + Assert.True(destination[0].IsEnabled); + } + + [Theory] + [InlineData(0, true)] + [InlineData(1, false)] + [InlineData(2, false)] + [InlineData(3, true)] + [InlineData(4, true)] + [InlineData(5, true)] + [InlineData(6, true)] + [InlineData(7, true)] + [InlineData(8, false)] + public void Populate_MarksOnlyTheHardwarePwmChannelsAsPwmCapable(int channelNumber, bool expected) + { + var destination = new List(); + + Create().Populate(new DaqifiOutMessage { DigitalPortNum = 9 }, Array.Empty(), destination); + + var channel = Assert.IsType(destination[channelNumber]); + Assert.Equal(expected, channel.IsPwmCapable); + } + + [Theory] + [InlineData(0u)] // not reported + [InlineData(AnalogChannel.MinResolution - 1)] + [InlineData(AnalogChannel.MaxResolution + 1)] + public void Populate_SubstitutesAssumedResolution_WhenReportedValueIsUnusable(uint reported) + { + var destination = new List(); + + Create().Populate( + new DaqifiOutMessage { AnalogInPortNum = 1, AnalogInRes = reported }, + Array.Empty(), + destination); + + var channel = Assert.IsType(destination[0]); + Assert.Equal(65535u, channel.Resolution); + Assert.True(channel.ResolutionIsAssumed); + } + + [Fact] + public void Populate_SubstitutesSafeDefaults_ForNonFiniteScalingValues() + { + // A corrupt status frame must not throw out of AnalogChannel's validating setters and + // abort population; the value is replaced and the population completes. + var message = new DaqifiOutMessage { AnalogInPortNum = 1, AnalogInRes = 65535 }; + message.AnalogInCalM.Add(float.NaN); + message.AnalogInCalB.Add(float.PositiveInfinity); + message.AnalogInIntScaleM.Add(0f); // zero is rejected for a multiplier + message.AnalogInPortRange.Add(-1f); + var destination = new List(); + + Create().Populate(message, Array.Empty(), destination); + + var channel = Assert.IsType(destination[0]); + Assert.Equal(1.0, channel.CalibrationM); + Assert.Equal(0.0, channel.CalibrationB); + Assert.Equal(1.0, channel.InternalScaleM); + Assert.Equal(1.0, channel.PortRange); + } + + [Fact] + public void Populate_UsesDefaults_WhenCalibrationArraysAreShorterThanTheChannelCount() + { + var message = new DaqifiOutMessage { AnalogInPortNum = 2, AnalogInRes = 65535 }; + message.AnalogInCalM.Add(2.5f); // only channel 0 described + var destination = new List(); + + Create().Populate(message, Array.Empty(), destination); + + Assert.Equal(2.5, ((AnalogChannel)destination[0]).CalibrationM, 5); + Assert.Equal(1.0, ((AnalogChannel)destination[1]).CalibrationM, 5); + } + + [Fact] + public void Populate_ReadsTheDeviceNameAtPopulateTime_NotAtConstruction() + { + // The name is supplied as a delegate precisely because it can change during the device's + // lifetime (a friendly-name write); a warning naming the old device would be misleading. + var logger = new CapturingLogger(); + var name = "Before"; + var populator = new StatusChannelPopulator(logger, () => name); + name = "After"; + + populator.Populate( + new DaqifiOutMessage { AnalogInPortNum = 1, AnalogInRes = 0 }, + Array.Empty(), + new List()); + + Assert.Contains("After", Assert.Single(logger.Warnings)); + } + + [Fact] + public void Populate_WithThrowingLogger_StillPopulates() + { + // The warnings are emitted exactly when the device reported something implausible, so a + // faulting consumer logger must not turn a recoverable bad frame into a failed population. + var destination = new List(); + + var ex = Record.Exception(() => Create(new ThrowingLogger()).Populate( + new DaqifiOutMessage { AnalogInPortNum = 2, AnalogInRes = 0 }, + Array.Empty(), + destination)); + + Assert.Null(ex); + Assert.Equal(2, destination.Count); + } + + private sealed class CapturingLogger : ILogger + { + public List Warnings { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (logLevel == LogLevel.Warning) + { + Warnings.Add(formatter(state, exception)); + } + } + } + + private sealed class ThrowingLogger : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => throw new InvalidOperationException("Consumer logger blew up."); + } +} diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs index 7dedd10..dc3f334 100644 --- a/src/Daqifi.Core/Device/DaqifiDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiDevice.cs @@ -4,6 +4,7 @@ using Daqifi.Core.Communication.Producers; using Daqifi.Core.Communication.Transport; using Daqifi.Core.Device.Capabilities; +using Daqifi.Core.Device.Internal; using Daqifi.Core.Device.Protocol; using Daqifi.Core.Firmware; using Microsoft.Extensions.Logging; @@ -393,6 +394,10 @@ protected void WithChannelsLock(Action action) // snapshot via SnapshotChannels for the device-level channel-management API. private readonly object _channelsLock = new(); + // Translates a status frame's channel description into channel instances. Stateless, so + // it is built once per device and reused for every population. + private readonly StatusChannelPopulator _channelPopulator; + /// /// Default time waits for the device to report its /// channel configuration (via the event) before @@ -599,6 +604,7 @@ public DaqifiDevice(string name, IPAddress? ipAddress = null, ILogger? logger = IpAddress = ipAddress; _status = ConnectionStatus.Disconnected; _logger = logger ?? NullLogger.Instance; + _channelPopulator = new StatusChannelPopulator(_logger, () => Name); } /// @@ -614,6 +620,7 @@ public DaqifiDevice(string name, Stream stream, IPAddress? ipAddress = null, ILo IpAddress = ipAddress; _status = ConnectionStatus.Disconnected; _logger = logger ?? NullLogger.Instance; + _channelPopulator = new StatusChannelPopulator(_logger, () => Name); _messageProducer = new MessageProducer(stream); _messageProducer.SendFailed += OnMessageSendFailed; _directStream = stream; @@ -630,6 +637,7 @@ public DaqifiDevice(string name, IStreamTransport transport, ILogger? logger = n Name = name; _status = ConnectionStatus.Disconnected; _logger = logger ?? NullLogger.Instance; + _channelPopulator = new StatusChannelPopulator(_logger, () => Name); _transport = transport; // Subscribe to transport status changes @@ -4118,41 +4126,19 @@ public virtual void PopulateChannelsFromStatus(DaqifiOutMessage message) TimestampFrequency = message.TimestampFreq; } - var analogCount = 0; - var digitalCount = 0; + int analogCount; + int digitalCount; IChannel[] channelsSnapshot; // Repopulate under the channels lock so a caller folding over a snapshot on // another thread (the device-level channel-management API) never observes a - // half-cleared or torn list. + // half-cleared or torn list. The mapping itself runs inside the lock exactly as it + // did before the extraction — it reads the current channels to reuse them in place. lock (_channelsLock) { - // Index existing channels by identity (type, number). Channels whose identity - // is unchanged are updated in place rather than replaced below, so consumer-held - // IChannel references — and the configuration on them (direction/output/PWM - // state) — survive a routine status re-population untouched. IsEnabled is the - // exception: analog channels resync it from the device-reported enabled mask - // (field 22) whenever the device sends one, so Core's view cannot silently drift - // from the device's (#409). - var existingByKey = new Dictionary<(ChannelType, int), IChannel>(); - foreach (var existing in _channels) - { - existingByKey[(existing.Type, existing.ChannelNumber)] = existing; - } - var updatedChannels = new List(); - // Populate analog input channels - if (message.AnalogInPortNum > 0) - { - analogCount = PopulateAnalogChannels(message, existingByKey, updatedChannels); - } - - // Populate digital channels - if (message.DigitalPortNum > 0) - { - digitalCount = PopulateDigitalChannels(message, existingByKey, updatedChannels); - } + (analogCount, digitalCount) = _channelPopulator.Populate(message, _channels, updatedChannels); _channels.Clear(); _channels.AddRange(updatedChannels); @@ -4169,197 +4155,6 @@ public virtual void PopulateChannelsFromStatus(DaqifiOutMessage message) digitalCount)); } - /// - /// Populates analog channels from the protobuf message, updating existing channel - /// instances in place where their identity (type, number) is unchanged. - /// - /// The protobuf message containing analog channel data. - /// Existing channels from the prior population, keyed by (type, number). - /// The list to append the resulting channel instances to, in order. - /// The number of analog channels populated. - private int PopulateAnalogChannels(DaqifiOutMessage message, Dictionary<(ChannelType, int), IChannel> existingByKey, List destination) - { - var analogInPortRanges = message.AnalogInPortRange; - var analogInCalibrationBValues = message.AnalogInCalB; - var analogInCalibrationMValues = message.AnalogInCalM; - var analogInInternalScaleMValues = message.AnalogInIntScaleM; - var analogInResolution = message.AnalogInRes; - var analogInPortEnabled = message.AnalogInPortEnabled; - - // Firmware before v3.5.0 never populates this field, so an empty byte string is - // ambiguous between "no channels enabled" and "not reported". Only trust it as the - // source of truth for IsEnabled when the device actually sent something. - var enabledIsReported = analogInPortEnabled.Length > 0; - - var count = (int)message.AnalogInPortNum; - - // Treat both a missing (0) and a physically-implausible out-of-range resolution as - // "assumed": the AnalogChannel constructor/setters now reject anything outside - // [MinResolution, MaxResolution], so passing a corrupt non-zero value straight through - // would throw and abort channel population mid-stream. Fall back to a safe default and - // log instead, so a corrupted status frame can neither crash population nor silently - // corrupt every scaled sample on the reuse path (UpdateScalingFromStatus below). - var resolutionIsAssumed = analogInResolution is < AnalogChannel.MinResolution or > AnalogChannel.MaxResolution; - var resolution = resolutionIsAssumed ? 65535u : analogInResolution; - - if (resolutionIsAssumed && count > 0) - { - SafeLog(() => _logger.LogWarning("[PopulateAnalogChannels] Device '{DeviceName}' reported no usable ADC resolution (analog_in_res={Resolution}) for {ChannelCount} analog channel(s); assuming {AssumedResolution}. Scaled samples on this device may be systematically wrong.", Name, analogInResolution, count, resolution)); - } - - for (var i = 0; i < count; i++) - { - var calibrationB = GetWithDefault(analogInCalibrationBValues, i, 0.0f); - var calibrationM = GetWithDefault(analogInCalibrationMValues, i, 1.0f); - var internalScaleM = GetWithDefault(analogInInternalScaleMValues, i, 1.0f); - var portRange = GetWithDefault(analogInPortRanges, i, 1.0f); - - // A corrupted device response can carry NaN/Infinity or physically nonsensical - // scaling coefficients. Feeding those into AnalogChannel would either throw from its - // validating setters (killing channel population mid-stream) or silently propagate - // garbage into every scaled sample. Fall back to safe defaults and log instead — - // mirroring the analog_in_res=0 handling above. - calibrationB = (float)SanitizeScalingValue(calibrationB, 0.0, AnalogChannel.MaxCalibrationMagnitude, requireNonZero: false, i, nameof(calibrationB)); - calibrationM = (float)SanitizeScalingValue(calibrationM, 1.0, AnalogChannel.MaxCalibrationMagnitude, requireNonZero: true, i, nameof(calibrationM)); - internalScaleM = (float)SanitizeScalingValue(internalScaleM, 1.0, AnalogChannel.MaxCalibrationMagnitude, requireNonZero: true, i, nameof(internalScaleM)); - portRange = (float)SanitizePortRange(portRange, i); - - if (existingByKey.TryGetValue((ChannelType.Analog, i), out var existing) && existing is AnalogChannel existingAnalog) - { - existingAnalog.UpdateScalingFromStatus(resolution, calibrationB, calibrationM, internalScaleM, portRange, resolutionIsAssumed); - if (enabledIsReported) - { - existingAnalog.IsEnabled = IsChannelBitSet(analogInPortEnabled, i); - } - destination.Add(existingAnalog); - continue; - } - - var channel = new AnalogChannel(i, resolution, resolutionIsAssumed) - { - Name = $"AI{i}", - Direction = ChannelDirection.Input, - IsEnabled = enabledIsReported && IsChannelBitSet(analogInPortEnabled, i), - CalibrationB = calibrationB, - CalibrationM = calibrationM, - InternalScaleM = internalScaleM, - PortRange = portRange - }; - - destination.Add(channel); - } - - return count; - } - - /// - /// Clamps a device-reported calibration/scale coefficient to a value - /// will accept, substituting and logging when the reported value is - /// non-finite, out of magnitude range, or (when ) zero. - /// - private double SanitizeScalingValue(double value, double fallback, double maxMagnitude, bool requireNonZero, int channelIndex, string fieldName) - { - var invalid = !double.IsFinite(value) - || Math.Abs(value) > maxMagnitude - || (requireNonZero && value == 0.0); - - if (invalid) - { - SafeLog(() => _logger.LogWarning("[PopulateAnalogChannels] Device '{DeviceName}' reported invalid {FieldName}={Value} for analog channel {ChannelIndex}; substituting {Fallback}. Scaled samples on this channel may be affected.", Name, fieldName, value, channelIndex, fallback)); - return fallback; - } - - return value; - } - - /// - /// Clamps a device-reported port range to a value will accept, - /// substituting the 1.0 default and logging when the reported value is non-finite, non-positive, - /// or beyond . - /// - private double SanitizePortRange(double value, int channelIndex) - { - if (!double.IsFinite(value) || value <= 0.0 || value > AnalogChannel.MaxPortRangeVolts) - { - SafeLog(() => _logger.LogWarning("[PopulateAnalogChannels] Device '{DeviceName}' reported invalid portRange={Value} for analog channel {ChannelIndex}; substituting 1.0. Scaled samples on this channel may be affected.", Name, value, channelIndex)); - return 1.0; - } - - return value; - } - - /// - /// Bitmask of digital channels whose hardware supports PWM output (bit n = channel n). - /// Channels 0, 3, 4, 5, 6 and 7 route to output-compare modules; the mask comes from the - /// firmware's board configuration and is identical across Nyquist variants. - /// - private const int PwmCapableChannelMask = 0x00F9; - - /// - /// Populates digital channels from the protobuf message, updating existing channel - /// instances in place where their identity (type, number) is unchanged. - /// - /// The protobuf message containing digital channel data. - /// Existing channels from the prior population, keyed by (type, number). - /// The list to append the resulting channel instances to, in order. - /// The number of digital channels populated. - private int PopulateDigitalChannels(DaqifiOutMessage message, Dictionary<(ChannelType, int), IChannel> existingByKey, List destination) - { - var count = (int)message.DigitalPortNum; - - for (var i = 0; i < count; i++) - { - var isPwmCapable = i < 32 && (PwmCapableChannelMask & (1 << i)) != 0; - - if (existingByKey.TryGetValue((ChannelType.Digital, i), out var existing) && existing is DigitalChannel existingDigital) - { - existingDigital.IsPwmCapable = isPwmCapable; - destination.Add(existingDigital); - continue; - } - - var channel = new DigitalChannel(i, isPwmCapable) - { - Name = $"DIO{i}", - Direction = ChannelDirection.Input, - IsEnabled = false - }; - - destination.Add(channel); - } - - return count; - } - - /// - /// Reads bit from a device-reported per-channel enable - /// bitmask (analog_in_port_enabled, field 22 — confirmed bit-packed on the bench: 2 bytes - /// for 16 channels, little-endian, bit n = channel n — the same layout Core - /// sends outbound via ). - /// Returns false when the channel number falls outside the bytes actually sent. - /// - private static bool IsChannelBitSet(Google.Protobuf.ByteString mask, int channelNumber) - { - var byteIndex = channelNumber / 8; - return byteIndex < mask.Length && (mask[byteIndex] & (1 << (channelNumber % 8))) != 0; - } - - /// - /// Gets a value from a list with a default fallback if the index is out of range. - /// - /// The list to get the value from. - /// The index to retrieve. - /// The default value if the index is out of range. - /// The value at the index or the default value. - private static T GetWithDefault(IList list, int index, T defaultValue) - { - if (list.Count > index) - { - return list[index]; - } - return defaultValue; - } - /// /// Handles streaming data messages received from the device. /// diff --git a/src/Daqifi.Core/Device/Internal/StatusChannelPopulator.cs b/src/Daqifi.Core/Device/Internal/StatusChannelPopulator.cs new file mode 100644 index 0000000..3ed6b5f --- /dev/null +++ b/src/Daqifi.Core/Device/Internal/StatusChannelPopulator.cs @@ -0,0 +1,307 @@ +using Daqifi.Core.Channel; +using Daqifi.Core.Communication.Messages; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; + +#nullable enable + +namespace Daqifi.Core.Device.Internal +{ + /// + /// Maps a device status frame's channel description onto instances, + /// extracted from so the device delegates rather than hosts it. + /// + /// + /// + /// This is the pure mapping half of : + /// protobuf fields in, channel instances out, plus the sanitization of the device-reported + /// calibration values. The device keeps everything that is not mapping — the channels lock, + /// the list swap, the timestamp-frequency update, and the ChannelsPopulated event — + /// because those are device state and device notification, not translation. + /// + /// + /// Holds no state of its own, so nothing here needs resetting between populations and it is + /// safe to call on whatever thread the device already holds its channels lock on. + /// + /// + internal sealed class StatusChannelPopulator + { + /// + /// Bitmask of digital channels whose hardware supports PWM output (bit n = channel n). + /// Channels 0, 3, 4, 5, 6 and 7 route to output-compare modules; the mask comes from the + /// firmware's board configuration and is identical across Nyquist variants. + /// + private const int PwmCapableChannelMask = 0x00F9; + + private readonly ILogger _logger; + private readonly Func _deviceName; + + /// + /// Creates a populator that logs against the owning device. + /// + /// The device's logger; warnings about implausible device-reported values go here. + /// + /// Reads the owning device's current name for those warnings. A delegate rather than a + /// captured string because the name can change during the device's lifetime, and a warning + /// naming the wrong device is worse than one naming none. + /// + internal StatusChannelPopulator(ILogger logger, Func deviceName) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _deviceName = deviceName ?? throw new ArgumentNullException(nameof(deviceName)); + } + + /// + /// Translates a status message's channel description into channel instances, appended to + /// in device order (analog first, then digital). + /// + /// The protobuf status message containing channel configuration. + /// + /// The channels from the prior population. Any whose identity (type, number) still appears + /// in is updated in place and re-used, so consumer-held + /// references — and the configuration on them (direction/output/PWM + /// state) — survive a routine status re-population untouched. IsEnabled is the + /// exception: analog channels resync it from the device-reported enabled mask (field 22) + /// whenever the device sends one, so Core's view cannot silently drift from the device's + /// (#409). + /// + /// The list to append the resulting channel instances to, in order. + /// How many analog and digital channels were populated. + internal (int AnalogCount, int DigitalCount) Populate( + DaqifiOutMessage message, + IReadOnlyList existing, + List destination) + { + var analogCount = 0; + var digitalCount = 0; + + // Index existing channels by identity (type, number) for the in-place reuse described + // on the parameter above. + var existingByKey = new Dictionary<(ChannelType, int), IChannel>(); + foreach (var channel in existing) + { + existingByKey[(channel.Type, channel.ChannelNumber)] = channel; + } + + // Populate analog input channels + if (message.AnalogInPortNum > 0) + { + analogCount = PopulateAnalogChannels(message, existingByKey, destination); + } + + // Populate digital channels + if (message.DigitalPortNum > 0) + { + digitalCount = PopulateDigitalChannels(message, existingByKey, destination); + } + + return (analogCount, digitalCount); + } + + /// + /// Populates analog channels from the protobuf message, updating existing channel + /// instances in place where their identity (type, number) is unchanged. + /// + /// The protobuf message containing analog channel data. + /// Existing channels from the prior population, keyed by (type, number). + /// The list to append the resulting channel instances to, in order. + /// The number of analog channels populated. + private int PopulateAnalogChannels(DaqifiOutMessage message, Dictionary<(ChannelType, int), IChannel> existingByKey, List destination) + { + var analogInPortRanges = message.AnalogInPortRange; + var analogInCalibrationBValues = message.AnalogInCalB; + var analogInCalibrationMValues = message.AnalogInCalM; + var analogInInternalScaleMValues = message.AnalogInIntScaleM; + var analogInResolution = message.AnalogInRes; + var analogInPortEnabled = message.AnalogInPortEnabled; + + // Firmware before v3.5.0 never populates this field, so an empty byte string is + // ambiguous between "no channels enabled" and "not reported". Only trust it as the + // source of truth for IsEnabled when the device actually sent something. + var enabledIsReported = analogInPortEnabled.Length > 0; + + var count = (int)message.AnalogInPortNum; + + // Treat both a missing (0) and a physically-implausible out-of-range resolution as + // "assumed": the AnalogChannel constructor/setters now reject anything outside + // [MinResolution, MaxResolution], so passing a corrupt non-zero value straight through + // would throw and abort channel population mid-stream. Fall back to a safe default and + // log instead, so a corrupted status frame can neither crash population nor silently + // corrupt every scaled sample on the reuse path (UpdateScalingFromStatus below). + var resolutionIsAssumed = analogInResolution is < AnalogChannel.MinResolution or > AnalogChannel.MaxResolution; + var resolution = resolutionIsAssumed ? 65535u : analogInResolution; + + if (resolutionIsAssumed && count > 0) + { + SafeLog(() => _logger.LogWarning("[PopulateAnalogChannels] Device '{DeviceName}' reported no usable ADC resolution (analog_in_res={Resolution}) for {ChannelCount} analog channel(s); assuming {AssumedResolution}. Scaled samples on this device may be systematically wrong.", _deviceName(), analogInResolution, count, resolution)); + } + + for (var i = 0; i < count; i++) + { + var calibrationB = GetWithDefault(analogInCalibrationBValues, i, 0.0f); + var calibrationM = GetWithDefault(analogInCalibrationMValues, i, 1.0f); + var internalScaleM = GetWithDefault(analogInInternalScaleMValues, i, 1.0f); + var portRange = GetWithDefault(analogInPortRanges, i, 1.0f); + + // A corrupted device response can carry NaN/Infinity or physically nonsensical + // scaling coefficients. Feeding those into AnalogChannel would either throw from its + // validating setters (killing channel population mid-stream) or silently propagate + // garbage into every scaled sample. Fall back to safe defaults and log instead — + // mirroring the analog_in_res=0 handling above. + calibrationB = (float)SanitizeScalingValue(calibrationB, 0.0, AnalogChannel.MaxCalibrationMagnitude, requireNonZero: false, i, nameof(calibrationB)); + calibrationM = (float)SanitizeScalingValue(calibrationM, 1.0, AnalogChannel.MaxCalibrationMagnitude, requireNonZero: true, i, nameof(calibrationM)); + internalScaleM = (float)SanitizeScalingValue(internalScaleM, 1.0, AnalogChannel.MaxCalibrationMagnitude, requireNonZero: true, i, nameof(internalScaleM)); + portRange = (float)SanitizePortRange(portRange, i); + + if (existingByKey.TryGetValue((ChannelType.Analog, i), out var existing) && existing is AnalogChannel existingAnalog) + { + existingAnalog.UpdateScalingFromStatus(resolution, calibrationB, calibrationM, internalScaleM, portRange, resolutionIsAssumed); + if (enabledIsReported) + { + existingAnalog.IsEnabled = IsChannelBitSet(analogInPortEnabled, i); + } + destination.Add(existingAnalog); + continue; + } + + var channel = new AnalogChannel(i, resolution, resolutionIsAssumed) + { + Name = $"AI{i}", + Direction = ChannelDirection.Input, + IsEnabled = enabledIsReported && IsChannelBitSet(analogInPortEnabled, i), + CalibrationB = calibrationB, + CalibrationM = calibrationM, + InternalScaleM = internalScaleM, + PortRange = portRange + }; + + destination.Add(channel); + } + + return count; + } + + /// + /// Clamps a device-reported calibration/scale coefficient to a value + /// will accept, substituting and logging when the reported value is + /// non-finite, out of magnitude range, or (when ) zero. + /// + private double SanitizeScalingValue(double value, double fallback, double maxMagnitude, bool requireNonZero, int channelIndex, string fieldName) + { + var invalid = !double.IsFinite(value) + || Math.Abs(value) > maxMagnitude + || (requireNonZero && value == 0.0); + + if (invalid) + { + SafeLog(() => _logger.LogWarning("[PopulateAnalogChannels] Device '{DeviceName}' reported invalid {FieldName}={Value} for analog channel {ChannelIndex}; substituting {Fallback}. Scaled samples on this channel may be affected.", _deviceName(), fieldName, value, channelIndex, fallback)); + return fallback; + } + + return value; + } + + /// + /// Clamps a device-reported port range to a value will accept, + /// substituting the 1.0 default and logging when the reported value is non-finite, non-positive, + /// or beyond . + /// + private double SanitizePortRange(double value, int channelIndex) + { + if (!double.IsFinite(value) || value <= 0.0 || value > AnalogChannel.MaxPortRangeVolts) + { + SafeLog(() => _logger.LogWarning("[PopulateAnalogChannels] Device '{DeviceName}' reported invalid portRange={Value} for analog channel {ChannelIndex}; substituting 1.0. Scaled samples on this channel may be affected.", _deviceName(), value, channelIndex)); + return 1.0; + } + + return value; + } + + /// + /// Populates digital channels from the protobuf message, updating existing channel + /// instances in place where their identity (type, number) is unchanged. + /// + /// The protobuf message containing digital channel data. + /// Existing channels from the prior population, keyed by (type, number). + /// The list to append the resulting channel instances to, in order. + /// The number of digital channels populated. + private static int PopulateDigitalChannels(DaqifiOutMessage message, Dictionary<(ChannelType, int), IChannel> existingByKey, List destination) + { + var count = (int)message.DigitalPortNum; + + for (var i = 0; i < count; i++) + { + var isPwmCapable = i < 32 && (PwmCapableChannelMask & (1 << i)) != 0; + + if (existingByKey.TryGetValue((ChannelType.Digital, i), out var existing) && existing is DigitalChannel existingDigital) + { + existingDigital.IsPwmCapable = isPwmCapable; + destination.Add(existingDigital); + continue; + } + + var channel = new DigitalChannel(i, isPwmCapable) + { + Name = $"DIO{i}", + Direction = ChannelDirection.Input, + IsEnabled = false + }; + + destination.Add(channel); + } + + return count; + } + + /// + /// Reads bit from a device-reported per-channel enable + /// bitmask (analog_in_port_enabled, field 22 — confirmed bit-packed on the bench: 2 bytes + /// for 16 channels, little-endian, bit n = channel n — the same layout Core + /// sends outbound via ). + /// Returns false when the channel number falls outside the bytes actually sent. + /// + private static bool IsChannelBitSet(Google.Protobuf.ByteString mask, int channelNumber) + { + var byteIndex = channelNumber / 8; + return byteIndex < mask.Length && (mask[byteIndex] & (1 << (channelNumber % 8))) != 0; + } + + /// + /// Gets a value from a list with a default fallback if the index is out of range. + /// + /// The list to get the value from. + /// The index to retrieve. + /// The default value if the index is out of range. + /// The value at the index or the default value. + private static T GetWithDefault(IList list, int index, T defaultValue) + { + if (list.Count > index) + { + return list[index]; + } + return defaultValue; + } + + /// + /// Runs a logging call, swallowing anything a consumer-supplied + /// throws. Mirrors DaqifiDevice.SafeLog, which is private to that class. + /// + /// + /// A logger that throws must not abort channel population: the warnings guarded here are + /// emitted precisely when the device reported something implausible, so a faulting logger + /// would turn a recoverable bad status frame into a failed population. + /// + private static void SafeLog(Action logAction) + { + try + { + logAction(); + } + catch + { + // A logger that throws is not permitted to affect device operation. + } + } + } +}