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.
+ }
+ }
+ }
+}