From 0fe32854c751d6a2c7f740ca273b2cd2eb8c1168 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Sat, 18 Jul 2026 09:11:29 -0600 Subject: [PATCH 1/2] feat(channel): validate AnalogChannel bounds and cover bipolar scaling (closes #300, closes #297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add bounds validation to AnalogChannel's resolution/range/calibration inputs so physically-nonsensical values can't silently produce wrong scaled samples (#300), and lock in signed/bipolar scaling behavior with explicit test coverage plus a range-polarity accessor for consuming UIs (#297). #300 — validation: - Constructor and setters now reject out-of-range resolution (outside the 255..16,777,216 max-count band), non-positive/oversized PortRange (0 < x <= 50 V), zero/NaN/Infinity/oversized scale factors (CalibrationM, InternalScaleM), NaN/Infinity/oversized CalibrationB, and non-finite Min/MaxValue. Bounds exposed as public consts. Negative CalibrationM is still allowed (signal inversion); zero CalibrationB is still allowed. - Device population (DaqifiDevice.PopulateAnalogChannels) sanitizes device-reported coefficients before they reach the validating setters: corrupt values fall back to safe defaults and log, mirroring the existing analog_in_res=0 handling — so a corrupted status frame can neither crash channel population nor propagate garbage into every scaled sample. #297 — bipolar/signed scaling: - Add explicit GetScaledValue coverage for negative/signed raw counts across representative bipolar range + calibration combinations (sign, zero-point, symmetry, offset-after-gain, inverting slope). - Add IAnalogChannel.IsBipolar (derived from the configured MinValue) so range-selection UIs can branch on polarity without hardcoding assumptions. Firmware confirmation of signed two's-complement emission remains tracked separately. Co-Authored-By: Claude Opus 4.8 --- .../Channel/AnalogChannelTests.cs | 190 ++++++++++++++++++ .../Device/ChannelPopulationTests.cs | 41 ++++ src/Daqifi.Core/Channel/AnalogChannel.cs | 156 +++++++++++++- src/Daqifi.Core/Channel/IAnalogChannel.cs | 7 + src/Daqifi.Core/Device/DaqifiDevice.cs | 46 +++++ 5 files changed, 432 insertions(+), 8 deletions(-) diff --git a/src/Daqifi.Core.Tests/Channel/AnalogChannelTests.cs b/src/Daqifi.Core.Tests/Channel/AnalogChannelTests.cs index 1690e140..88da208b 100644 --- a/src/Daqifi.Core.Tests/Channel/AnalogChannelTests.cs +++ b/src/Daqifi.Core.Tests/Channel/AnalogChannelTests.cs @@ -276,6 +276,196 @@ public void Properties_CanBeModified() Assert.Equal(5.0, channel.PortRange); } + // --------------------------------------------------------------------- + // Bounds validation (daqifi-core#300) + // --------------------------------------------------------------------- + + [Theory] + [InlineData(254u)] // just below the 8-bit max-count floor + [InlineData(16_777_217u)] // just above the 24-bit max-count ceiling + public void Constructor_WithOutOfRangeResolution_ThrowsException(uint resolution) + { + Assert.Throws(() => new AnalogChannel(channelNumber: 0, resolution: resolution)); + } + + [Theory] + [InlineData(255u)] // 8-bit max-count floor + [InlineData(16_777_216u)] // 24-bit ceiling + public void Constructor_WithBoundaryResolution_IsAccepted(uint resolution) + { + var channel = new AnalogChannel(channelNumber: 0, resolution: resolution); + Assert.Equal(resolution, channel.Resolution); + } + + [Theory] + [InlineData(0.0)] // zero range + [InlineData(-5.0)] // negative range + [InlineData(AnalogChannel.MaxPortRangeVolts + 0.1)] // beyond max + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void PortRange_WithInvalidValue_ThrowsException(double value) + { + var channel = new AnalogChannel(0); + Assert.Throws(() => channel.PortRange = value); + } + + [Theory] + [InlineData(0.0)] // zero scale factor discards the measurement + [InlineData(double.NaN)] + [InlineData(double.NegativeInfinity)] + [InlineData(AnalogChannel.MaxCalibrationMagnitude * 2)] + public void CalibrationM_WithInvalidValue_ThrowsException(double value) + { + var channel = new AnalogChannel(0); + Assert.Throws(() => channel.CalibrationM = value); + } + + [Fact] + public void CalibrationM_WithNegativeValue_IsAccepted() + { + // A negative slope legitimately inverts the signal (e.g. reversed wiring). + var channel = new AnalogChannel(0) { CalibrationM = -2.5 }; + Assert.Equal(-2.5, channel.CalibrationM); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + [InlineData(AnalogChannel.MaxCalibrationMagnitude * 2)] + public void CalibrationB_WithInvalidValue_ThrowsException(double value) + { + var channel = new AnalogChannel(0); + Assert.Throws(() => channel.CalibrationB = value); + } + + [Fact] + public void CalibrationB_WithZero_IsAccepted() + { + // Zero is a valid offset (it's the default). + var channel = new AnalogChannel(0) { CalibrationB = 0.0 }; + Assert.Equal(0.0, channel.CalibrationB); + } + + [Theory] + [InlineData(0.0)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void InternalScaleM_WithInvalidValue_ThrowsException(double value) + { + var channel = new AnalogChannel(0); + Assert.Throws(() => channel.InternalScaleM = value); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.NegativeInfinity)] + public void MinValue_WithNonFinite_ThrowsException(double value) + { + var channel = new AnalogChannel(0); + Assert.Throws(() => channel.MinValue = value); + } + + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void MaxValue_WithNonFinite_ThrowsException(double value) + { + var channel = new AnalogChannel(0); + Assert.Throws(() => channel.MaxValue = value); + } + + [Fact] + public void PortRange_AtMaxBoundary_IsAccepted() + { + var channel = new AnalogChannel(0) { PortRange = AnalogChannel.MaxPortRangeVolts }; + Assert.Equal(AnalogChannel.MaxPortRangeVolts, channel.PortRange); + } + + // --------------------------------------------------------------------- + // Bipolar / signed scaling (daqifi-core#297) + // --------------------------------------------------------------------- + + [Fact] + public void GetScaledValue_WithNegativeRawValue_ProducesNegativeVoltage() + { + // ±10V bipolar range: signed two's-complement raw counts should map straight through + // to signed voltages with no unipolar-only assumption in the formula. + var channel = new AnalogChannel(0, 262143) + { + PortRange = 10.0, + CalibrationM = 1.0, + CalibrationB = 0.0, + InternalScaleM = 1.0, + MinValue = -10.0, + MaxValue = 10.0 + }; + + // -full scale -> -PortRange + Assert.Equal(-10.0, channel.GetScaledValue(-262143), precision: 6); + // -half scale -> -PortRange/2 + Assert.Equal(-5.0, channel.GetScaledValue(-131072), precision: 2); + // zero raw -> 0 V (no offset) + Assert.Equal(0.0, channel.GetScaledValue(0), precision: 6); + } + + [Fact] + public void GetScaledValue_IsSymmetricAboutZeroForBipolarRange() + { + var channel = new AnalogChannel(0, 65535) + { + PortRange = 5.0, + CalibrationM = 1.0, + CalibrationB = 0.0, + InternalScaleM = 1.0 + }; + + var positive = channel.GetScaledValue(20000); + var negative = channel.GetScaledValue(-20000); + + Assert.Equal(-positive, negative, precision: 9); + } + + [Fact] + public void GetScaledValue_WithNegativeRawAndOffset_AppliesOffsetAfterSignedGain() + { + // Formula: (raw/Res * PortRange * M + B) * InternalScaleM. + // At -full scale with M=1, B=1: (-1 * 10 * 1 + 1) = -9. + var channel = new AnalogChannel(0, 262143) + { + PortRange = 10.0, + CalibrationM = 1.0, + CalibrationB = 1.0, + InternalScaleM = 1.0 + }; + + Assert.Equal(-9.0, channel.GetScaledValue(-262143), precision: 6); + } + + [Fact] + public void GetScaledValue_WithNegativeCalibrationM_InvertsSign() + { + var channel = new AnalogChannel(0, 65535) + { + PortRange = 10.0, + CalibrationM = -1.0, + CalibrationB = 0.0, + InternalScaleM = 1.0 + }; + + // A negative raw with an inverting slope yields a positive voltage. + Assert.Equal(10.0, channel.GetScaledValue(-65535), precision: 6); + } + + [Fact] + public void IsBipolar_ReflectsConfiguredMinValue() + { + var bipolar = new AnalogChannel(0) { MinValue = -10.0, MaxValue = 10.0 }; + Assert.True(bipolar.IsBipolar); + + var unipolar = new AnalogChannel(0) { MinValue = 0.0, MaxValue = 10.0 }; + Assert.False(unipolar.IsBipolar); + } + [Fact] public void ToString_ReturnsChannelName() { diff --git a/src/Daqifi.Core.Tests/Device/ChannelPopulationTests.cs b/src/Daqifi.Core.Tests/Device/ChannelPopulationTests.cs index e3712d4d..3005c9a4 100644 --- a/src/Daqifi.Core.Tests/Device/ChannelPopulationTests.cs +++ b/src/Daqifi.Core.Tests/Device/ChannelPopulationTests.cs @@ -229,6 +229,47 @@ public void PopulateChannelsFromStatus_AnalogChannelsHaveCorrectCalibrationParam Assert.Equal(5.0, analogChannels[1].PortRange, 3); } + [Fact] + public void PopulateChannelsFromStatus_WithCorruptScalingValues_SubstitutesSafeDefaultsWithoutThrowing() + { + // A corrupted device response can carry NaN/Infinity or nonsensical coefficients. Population + // must not throw (which would abort channel population mid-stream) and must fall back to safe + // defaults rather than propagating garbage into every scaled sample (daqifi-core#300). + var device = new DaqifiDevice("TestDevice"); + var message = new DaqifiOutMessage + { + AnalogInPortNum = 2, + AnalogInRes = 65535 + }; + message.AnalogInCalM.Add(float.NaN); // ch0: invalid -> default 1.0 + message.AnalogInCalM.Add(2.0f); // ch1: valid + message.AnalogInCalB.Add(float.PositiveInfinity); // ch0: invalid -> default 0.0 + message.AnalogInCalB.Add(0.2f); // ch1: valid + message.AnalogInIntScaleM.Add(0.0f); // ch0: zero scale -> default 1.0 + message.AnalogInIntScaleM.Add(1.2f); // ch1: valid + message.AnalogInPortRange.Add(-10.0f); // ch0: negative range -> default 1.0 + message.AnalogInPortRange.Add(5.0f); // ch1: valid + + device.PopulateChannelsFromStatus(message); + + var analogChannels = device.Channels + .Where(c => c.Type == ChannelType.Analog) + .Cast() + .ToList(); + + // ch0 fell back to defaults across the board + Assert.Equal(1.0, analogChannels[0].CalibrationM, 3); + Assert.Equal(0.0, analogChannels[0].CalibrationB, 3); + Assert.Equal(1.0, analogChannels[0].InternalScaleM, 3); + Assert.Equal(1.0, analogChannels[0].PortRange, 3); + + // ch1's valid values were preserved + Assert.Equal(2.0, analogChannels[1].CalibrationM, 3); + Assert.Equal(0.2, analogChannels[1].CalibrationB, 3); + Assert.Equal(1.2, analogChannels[1].InternalScaleM, 3); + Assert.Equal(5.0, analogChannels[1].PortRange, 3); + } + [Fact] public void PopulateChannelsFromStatus_AnalogChannelsHaveCorrectResolution() { diff --git a/src/Daqifi.Core/Channel/AnalogChannel.cs b/src/Daqifi.Core/Channel/AnalogChannel.cs index 90079e8a..7a4df253 100644 --- a/src/Daqifi.Core/Channel/AnalogChannel.cs +++ b/src/Daqifi.Core/Channel/AnalogChannel.cs @@ -5,6 +5,33 @@ namespace Daqifi.Core.Channel; /// public class AnalogChannel : IAnalogChannel { + /// + /// Smallest (maximum raw count) accepted as a physically plausible ADC + /// resolution — 255, i.e. 2^8 - 1 for an 8-bit converter. is stored as + /// the maximum raw count (2^bits - 1), not the bit depth, so this is the max-count for 8 bits. + /// + public const uint MinResolution = 255; + + /// + /// Largest (maximum raw count) accepted as a physically plausible ADC + /// resolution — 16,777,216, covering 24-bit converters whether reported as 2^24 or 2^24 - 1. + /// + public const uint MaxResolution = 16_777_216; + + /// + /// Largest absolute , in volts, accepted as physically reasonable. DAQiFi + /// hardware tops out at ±10V differential ranges; 50 leaves generous headroom while still + /// rejecting nonsensical values. + /// + public const double MaxPortRangeVolts = 50.0; + + /// + /// Largest absolute magnitude accepted for the multiplicative/offset calibration coefficients + /// (, , ). Values + /// beyond this indicate a corrupted coefficient rather than a real calibration. + /// + public const double MaxCalibrationMagnitude = 1_000_000.0; + private readonly object _lock = new(); private IDataSample? _activeSample; private string _name; @@ -76,7 +103,11 @@ public IDataSample? ActiveSample public double MinValue { get { lock (_lock) { return _minValue; } } - set { lock (_lock) { _minValue = value; } } + set + { + RequireFinite(value, nameof(MinValue)); + lock (_lock) { _minValue = value; } + } } /// @@ -85,7 +116,28 @@ public double MinValue public double MaxValue { get { lock (_lock) { return _maxValue; } } - set { lock (_lock) { _maxValue = value; } } + set + { + RequireFinite(value, nameof(MaxValue)); + lock (_lock) { _maxValue = value; } + } + } + + /// + /// Gets whether the configured display range (..) is + /// bipolar — i.e. spans negative voltages, as the ±1V/±5V/±10V differential ranges do — rather + /// than unipolar (0V-and-up). Derived purely from , which a consumer sets + /// when selecting a range; lets range-selection UI branch on polarity without hardcoding + /// per-device assumptions. + /// + /// + /// Whether the device actually emits signed two's-complement raw counts and per-range calibration + /// for a given bipolar range is firmware-dependent and tracked separately (daqifi-core#297); this + /// property reflects the configured range only. + /// + public bool IsBipolar + { + get { lock (_lock) { return _minValue < 0.0; } } } /// @@ -119,7 +171,11 @@ public bool ResolutionIsAssumed public double CalibrationM { get { lock (_lock) { return _calibrationM; } } - set { lock (_lock) { _calibrationM = value; } } + set + { + ValidateScaleFactor(value, nameof(CalibrationM)); + lock (_lock) { _calibrationM = value; } + } } /// @@ -128,7 +184,11 @@ public double CalibrationM public double CalibrationB { get { lock (_lock) { return _calibrationB; } } - set { lock (_lock) { _calibrationB = value; } } + set + { + ValidateOffset(value, nameof(CalibrationB)); + lock (_lock) { _calibrationB = value; } + } } /// @@ -137,7 +197,11 @@ public double CalibrationB public double InternalScaleM { get { lock (_lock) { return _internalScaleM; } } - set { lock (_lock) { _internalScaleM = value; } } + set + { + ValidateScaleFactor(value, nameof(InternalScaleM)); + lock (_lock) { _internalScaleM = value; } + } } /// @@ -146,7 +210,11 @@ public double InternalScaleM public double PortRange { get { lock (_lock) { return _portRange; } } - set { lock (_lock) { _portRange = value; } } + set + { + ValidatePortRange(value, nameof(PortRange)); + lock (_lock) { _portRange = value; } + } } /// @@ -167,8 +235,7 @@ public AnalogChannel(int channelNumber, uint resolution = 65535, bool resolution if (channelNumber < 0) throw new ArgumentOutOfRangeException(nameof(channelNumber), "Channel number must be non-negative."); - if (resolution == 0) - throw new ArgumentOutOfRangeException(nameof(resolution), "Resolution must be greater than zero."); + ValidateResolution(resolution, nameof(resolution)); ChannelNumber = channelNumber; _resolution = resolution; @@ -257,4 +324,77 @@ public override string ToString() { return Name; } + + /// + /// Validates that is a physically plausible ADC max-count, in + /// ... + /// + /// Thrown when is outside the valid range. + internal static void ValidateResolution(uint resolution, string paramName) + { + if (resolution is < MinResolution or > MaxResolution) + { + throw new ArgumentOutOfRangeException( + paramName, resolution, + $"Resolution must be a plausible ADC max-count between {MinResolution} and {MaxResolution}."); + } + } + + /// + /// Validates that is a physically reasonable port (voltage) range: + /// finite, positive, and no larger than . + /// + /// Thrown when is not a valid range. + internal static void ValidatePortRange(double value, string paramName) + { + if (!double.IsFinite(value) || value <= 0.0 || value > MaxPortRangeVolts) + { + throw new ArgumentOutOfRangeException( + paramName, value, + $"Port range must be a finite value in (0, {MaxPortRangeVolts}] volts."); + } + } + + /// + /// Validates a multiplicative scale factor (/): + /// finite, non-zero, and within ±. Negative factors are allowed + /// (they invert the signal); zero is not (it discards the measurement entirely). + /// + /// Thrown when is not a valid scale factor. + internal static void ValidateScaleFactor(double value, string paramName) + { + if (!double.IsFinite(value) || value == 0.0 || Math.Abs(value) > MaxCalibrationMagnitude) + { + throw new ArgumentOutOfRangeException( + paramName, value, + $"Scale factor must be a finite, non-zero value within ±{MaxCalibrationMagnitude}."); + } + } + + /// + /// Validates a calibration offset (): finite and within + /// ±. Zero is a valid offset. + /// + /// Thrown when is not a valid offset. + internal static void ValidateOffset(double value, string paramName) + { + if (!double.IsFinite(value) || Math.Abs(value) > MaxCalibrationMagnitude) + { + throw new ArgumentOutOfRangeException( + paramName, value, + $"Calibration offset must be a finite value within ±{MaxCalibrationMagnitude}."); + } + } + + /// + /// Validates that is finite (rejects NaN/Infinity). + /// + /// Thrown when is not finite. + internal static void RequireFinite(double value, string paramName) + { + if (!double.IsFinite(value)) + { + throw new ArgumentOutOfRangeException(paramName, value, "Value must be a finite number."); + } + } } diff --git a/src/Daqifi.Core/Channel/IAnalogChannel.cs b/src/Daqifi.Core/Channel/IAnalogChannel.cs index b571bd8b..b9bc6353 100644 --- a/src/Daqifi.Core/Channel/IAnalogChannel.cs +++ b/src/Daqifi.Core/Channel/IAnalogChannel.cs @@ -15,6 +15,13 @@ public interface IAnalogChannel : IChannel /// double MaxValue { get; set; } + /// + /// Gets whether the configured display range (..) is + /// bipolar (spans negative voltages, as ±1V/±5V/±10V differential ranges do) rather than unipolar + /// (0V-and-up). Lets range-selection UI branch on polarity without hardcoding per-device assumptions. + /// + bool IsBipolar { get; } + /// /// Gets the resolution of the ADC, expressed as the maximum raw count (e.g., 65535 for 16-bit, /// 262143 for 18-bit) — i.e. 2^bits - 1, not 2^bits. This value is a direct divisor in diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs index d8f1c2ef..ea2dc6c7 100644 --- a/src/Daqifi.Core/Device/DaqifiDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiDevice.cs @@ -1343,6 +1343,16 @@ private int PopulateAnalogChannels(DaqifiOutMessage message, Dictionary<(Channel 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); @@ -1367,6 +1377,42 @@ private int PopulateAnalogChannels(DaqifiOutMessage message, Dictionary<(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) + { + Trace.WriteLine($"[PopulateAnalogChannels] Device '{Name}' reported invalid {fieldName}={value} for analog channel {channelIndex}; substituting {fallback}. Scaled samples on this channel may be affected."); + 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) + { + Trace.WriteLine($"[PopulateAnalogChannels] Device '{Name}' reported invalid portRange={value} for analog channel {channelIndex}; substituting 1.0. Scaled samples on this channel may be affected."); + 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 From 1f4302d45c1329c5c5c65f018d8a99f327a7674a Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Sat, 18 Jul 2026 11:10:22 -0600 Subject: [PATCH 2/2] fix(device): sanitize out-of-range ADC resolution during population (Qodo #328) PopulateAnalogChannels only treated analog_in_res==0 as needing a fallback, so a corrupted status frame carrying a non-zero out-of-range resolution (e.g. 1, uint.MaxValue) would reach the AnalogChannel constructor's new ValidateResolution check, throw, and abort channel population mid-stream. It would also install a bad resolution into reused channels via UpdateScalingFromStatus. Extend the existing "assumed" fallback to cover anything outside [MinResolution, MaxResolution], so both the new-channel and reuse paths receive a sanitized resolution. UpdateScalingFromStatus stays a non-throwing trusted writer; the status boundary remains the single sanitization point. Co-Authored-By: Claude Opus 4.8 --- .../Device/ChannelPopulationTests.cs | 34 +++++++++++++++++++ src/Daqifi.Core/Device/DaqifiDevice.cs | 13 +++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/ChannelPopulationTests.cs b/src/Daqifi.Core.Tests/Device/ChannelPopulationTests.cs index 3005c9a4..3fd912f8 100644 --- a/src/Daqifi.Core.Tests/Device/ChannelPopulationTests.cs +++ b/src/Daqifi.Core.Tests/Device/ChannelPopulationTests.cs @@ -270,6 +270,40 @@ public void PopulateChannelsFromStatus_WithCorruptScalingValues_SubstitutesSafeD Assert.Equal(5.0, analogChannels[1].PortRange, 3); } + [Theory] + [InlineData(0u)] // missing resolution + [InlineData(1u)] // non-zero but below MinResolution + [InlineData(uint.MaxValue)] // above MaxResolution + public void PopulateChannelsFromStatus_WithUnusableResolution_FallsBackWithoutThrowing(uint reportedResolution) + { + // A non-zero but out-of-range AnalogInRes must not reach the AnalogChannel constructor + // (which now rejects it) and abort channel population — it should fall back to the assumed + // default and flag ResolutionIsAssumed, on both the new-channel and reuse paths. + var device = new DaqifiDevice("TestDevice"); + var message = new DaqifiOutMessage + { + AnalogInPortNum = 2, + AnalogInRes = reportedResolution + }; + + // First population creates the channels; second re-populates (exercises the reuse path via + // UpdateScalingFromStatus) — neither should throw. + device.PopulateChannelsFromStatus(message); + device.PopulateChannelsFromStatus(message); + + var analogChannels = device.Channels + .Where(c => c.Type == ChannelType.Analog) + .Cast() + .ToList(); + + Assert.Equal(2, analogChannels.Count); + foreach (var ch in analogChannels) + { + Assert.Equal(65535u, ch.Resolution); + Assert.True(ch.ResolutionIsAssumed); + } + } + [Fact] public void PopulateChannelsFromStatus_AnalogChannelsHaveCorrectResolution() { diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs index ea2dc6c7..70ae49a2 100644 --- a/src/Daqifi.Core/Device/DaqifiDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiDevice.cs @@ -1328,12 +1328,19 @@ private int PopulateAnalogChannels(DaqifiOutMessage message, Dictionary<(Channel var analogInResolution = message.AnalogInRes; var count = (int)message.AnalogInPortNum; - var resolutionIsAssumed = analogInResolution == 0; - var resolution = analogInResolution > 0 ? analogInResolution : 65535; + + // 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) { - Trace.WriteLine($"[PopulateAnalogChannels] Device '{Name}' reported no ADC resolution (analog_in_res=0) for {count} analog channel(s); assuming {resolution}. Scaled samples on this device may be systematically wrong."); + Trace.WriteLine($"[PopulateAnalogChannels] Device '{Name}' reported no usable ADC resolution (analog_in_res={analogInResolution}) for {count} analog channel(s); assuming {resolution}. Scaled samples on this device may be systematically wrong."); } for (var i = 0; i < count; i++)