From abc32e692e5a7f735372c21742366a6a01a3c591 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Sun, 19 Jul 2026 09:02:14 -0600 Subject: [PATCH 1/3] fix(device): suppress firmware's malformed warmup stream frame (closes #351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The firmware's fast streaming encoder emits a leading frame carrying fewer analog values than the enabled channel mask (a warmup frame with a normal one-sample-period timestamp). Core decoded it unfiltered, so every per-channel consumer (IChannel.SampleReceived, the StreamSamplesAsync live stream) received a partial first DataSample — silently corrupting first-value baselining, gap detection, min/max, and calibration/export. Gate the decode path at stream start: arm a first-full-frame guard in StartStreaming and, before timestamp/gap processing, drop any leading analog-bearing frame whose value count is below the enabled-analog count. The suppressed frame is a complete non-event (the next frame anchors the session clock), only *leading* short frames are dropped (mid-stream short frames stay best-effort mapped), and suppression is bounded by MaxSuppressedWarmupFrames so a genuinely short stream is never withheld. Raw MessageReceived still fires, so hand-demuxing consumers are unaffected. Bench-validated on Nq1 (FW 3.7.2, USB/serial): the first decoded LiveSample frame now carries the full [0,1] complement instead of a single value. Co-Authored-By: Claude Opus 4.8 --- .../DaqifiStreamingDeviceDecodeTests.cs | 200 +++++++++++++++++- .../Device/DaqifiStreamingDevice.cs | 72 ++++++- 2 files changed, 264 insertions(+), 8 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs index dc1770e7..9bd55b2e 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs @@ -455,8 +455,10 @@ public void Decode_MoreValuesThanChannels_MapsAvailableWithoutThrowing() } [Fact] - public void Decode_FewerValuesThanChannels_MapsAvailableWithoutThrowing() + public void Decode_MidStreamFewerValuesThanChannels_MapsAvailableWithoutThrowing() { + // The warmup guard (issue #351) only suppresses *leading* short frames. Once a full frame + // has been seen, a later short frame is still best-effort mapped rather than dropped. var device = CreateStreamingDevice(analogCount: 2); var ai0 = AnalogChannel(device, 0); var ai1 = AnalogChannel(device, 1); @@ -464,14 +466,21 @@ public void Decode_FewerValuesThanChannels_MapsAvailableWithoutThrowing() ai1.IsEnabled = true; device.StartStreaming(); - var frame = new DaqifiOutMessage { MsgTimeStamp = 1 }; - frame.AnalogInDataFloat.Add(1f); // only one value for two enabled channels + // First a full frame to clear the warmup guard. + var full = new DaqifiOutMessage { MsgTimeStamp = 1 }; + full.AnalogInDataFloat.Add(9f); + full.AnalogInDataFloat.Add(9f); + device.InvokeStreamMessage(full); + + // Then a mid-stream short frame: one value for two enabled channels. + var frame = new DaqifiOutMessage { MsgTimeStamp = 2 }; + frame.AnalogInDataFloat.Add(1f); var ex = Record.Exception(() => device.InvokeStreamMessage(frame)); Assert.Null(ex); Assert.Equal(1.0, ai0.ActiveSample!.Value); - Assert.Null(ai1.ActiveSample); + Assert.Equal(9.0, ai1.ActiveSample!.Value); // retains its last (full-frame) value } [Fact] @@ -499,6 +508,189 @@ public void Decode_CarriesDeviceTimestampVerbatimAcrossFrames() #endregion + #region Warmup-frame suppression (issue #351) + + [Fact] + public void Decode_SuppressesMalformedFirstFrame_ThenEmitsFullFrame() + { + // Reproduces the bench evidence: 2 enabled analog channels, first frame carries a single + // analog value (a firmware warmup frame). That partial first sample must not reach the + // channels; the next full frame must decode normally. + var device = CreateStreamingDevice(analogCount: 2); + var ai0 = AnalogChannel(device, 0); + var ai1 = AnalogChannel(device, 1); + ai0.IsEnabled = true; + ai1.IsEnabled = true; + device.StartStreaming(); + + var samples = new List(); + ai0.SampleReceived += (_, e) => samples.Add(e.Sample.Value); + + // Malformed first frame: one value for two enabled channels. + var warmup = new DaqifiOutMessage { MsgTimeStamp = 1000 }; + warmup.AnalogInDataFloat.Add(0.1f); + device.InvokeStreamMessage(warmup); + + Assert.Null(ai0.ActiveSample); // warmup frame suppressed + Assert.Null(ai1.ActiveSample); + Assert.Empty(samples); + + // Next full frame decodes for both channels. + var full = new DaqifiOutMessage { MsgTimeStamp = 1840 }; + full.AnalogInDataFloat.Add(4f); + full.AnalogInDataFloat.Add(8f); + device.InvokeStreamMessage(full); + + Assert.Equal(4.0, ai0.ActiveSample!.Value); + Assert.Equal(8.0, ai1.ActiveSample!.Value); + Assert.Equal(new[] { 4.0 }, samples); // AI0 saw exactly one (correct) sample + } + + [Fact] + public void Decode_SuppressedWarmupFrame_DoesNotAnchorSessionClock() + { + // A suppressed warmup frame must be a complete non-event: the *next* frame anchors the + // timestamp baseline, so no spurious gap is reported from the warmup-to-first delta. + var device = CreateStreamingDevice(analogCount: 2); + AnalogChannel(device, 0).IsEnabled = true; + AnalogChannel(device, 1).IsEnabled = true; + device.StartStreaming(); + + var gaps = new List(); + device.GapDetected += (_, e) => gaps.Add(e); + + var warmup = new DaqifiOutMessage { MsgTimeStamp = 1000 }; + warmup.AnalogInDataFloat.Add(0.1f); + device.InvokeStreamMessage(warmup); + + // Steady full-frame cadence after the warmup frame. + for (uint ts = 100000; ts <= 105000; ts += 1000) + { + var frame = new DaqifiOutMessage { MsgTimeStamp = ts }; + frame.AnalogInDataFloat.Add(1f); + frame.AnalogInDataFloat.Add(2f); + device.InvokeStreamMessage(frame); + } + + // Had the warmup frame (ts=1000) anchored the clock, the jump to ts=100000 would trip a gap. + Assert.Empty(gaps); + } + + [Fact] + public void Decode_WarmupFrame_StillReRaisesRawMessage() + { + // Suppression skips only the per-channel decode; raw-frame consumers still see the frame. + var device = CreateStreamingDevice(analogCount: 2); + AnalogChannel(device, 0).IsEnabled = true; + AnalogChannel(device, 1).IsEnabled = true; + device.StartStreaming(); + + var rawFrames = 0; + device.MessageReceived += (_, _) => rawFrames++; + + var warmup = new DaqifiOutMessage { MsgTimeStamp = 1 }; + warmup.AnalogInDataFloat.Add(0.1f); + device.InvokeStreamMessage(warmup); + + Assert.Equal(1, rawFrames); + } + + [Fact] + public void Decode_FullFirstFrame_NotSuppressed() + { + // A first frame that already carries the full complement decodes immediately. + var device = CreateStreamingDevice(analogCount: 2); + var ai0 = AnalogChannel(device, 0); + var ai1 = AnalogChannel(device, 1); + ai0.IsEnabled = true; + ai1.IsEnabled = true; + device.StartStreaming(); + + var frame = new DaqifiOutMessage { MsgTimeStamp = 1 }; + frame.AnalogInDataFloat.Add(1f); + frame.AnalogInDataFloat.Add(2f); + device.InvokeStreamMessage(frame); + + Assert.Equal(1.0, ai0.ActiveSample!.Value); + Assert.Equal(2.0, ai1.ActiveSample!.Value); + } + + [Fact] + public void Decode_DigitalOnlyStream_FirstFrameNotSuppressed() + { + // With no analog channels enabled the warmup guard never engages: a digital-only first + // frame is decoded normally. + var device = CreateStreamingDevice(analogCount: 0, digitalCount: 4); + var dio = Enumerable.Range(0, 4).Select(n => DigitalChannel(device, n)).ToList(); + foreach (var d in dio) d.IsEnabled = true; + device.StartStreaming(); + + var frame = new DaqifiOutMessage { MsgTimeStamp = 1 }; + frame.DigitalData = ByteString.CopyFrom(new byte[] { 0b1010 }); + device.InvokeStreamMessage(frame); + + Assert.Equal(0.0, dio[0].ActiveSample!.Value); + Assert.Equal(1.0, dio[1].ActiveSample!.Value); + } + + [Fact] + public void Decode_WarmupGuardReArmsForEachSession() + { + // The guard is re-armed at every StartStreaming, so a warmup frame is suppressed at the + // start of a *subsequent* session too. + var device = CreateStreamingDevice(analogCount: 2); + var ai0 = AnalogChannel(device, 0); + var ai1 = AnalogChannel(device, 1); + ai0.IsEnabled = true; + ai1.IsEnabled = true; + + // Session 1: warmup + a full frame. + device.StartStreaming(); + var w1 = new DaqifiOutMessage { MsgTimeStamp = 1 }; + w1.AnalogInDataFloat.Add(0.1f); + device.InvokeStreamMessage(w1); + var f1 = new DaqifiOutMessage { MsgTimeStamp = 2 }; + f1.AnalogInDataFloat.Add(1f); + f1.AnalogInDataFloat.Add(2f); + device.InvokeStreamMessage(f1); + device.StopStreaming(); + + // Session 2: a fresh warmup frame must again be suppressed. + device.StartStreaming(); + var w2 = new DaqifiOutMessage { MsgTimeStamp = 3 }; + w2.AnalogInDataFloat.Add(5f); // single value -> partial again + device.InvokeStreamMessage(w2); + + // AI1 still holds session-1's value; the session-2 warmup frame did not overwrite AI0. + Assert.Equal(1.0, ai0.ActiveSample!.Value); + Assert.Equal(2.0, ai1.ActiveSample!.Value); + } + + [Fact] + public void Decode_PersistentShortFrames_ReleasedAfterCap() + { + // Safety bound: a stream that only ever sends short frames must not be withheld forever. + // After MaxSuppressedWarmupFrames (5) suppressed frames, the guard releases. + var device = CreateStreamingDevice(analogCount: 2); + var ai0 = AnalogChannel(device, 0); + AnalogChannel(device, 1).IsEnabled = true; + ai0.IsEnabled = true; + device.StartStreaming(); + + // 5 suppressed, the 6th is released (best-effort mapped). + for (var i = 0; i < 6; i++) + { + var frame = new DaqifiOutMessage { MsgTimeStamp = (uint)(1000 + i) }; + frame.AnalogInDataFloat.Add(i); + device.InvokeStreamMessage(frame); + } + + Assert.NotNull(ai0.ActiveSample); + Assert.Equal(5.0, ai0.ActiveSample!.Value); // the 6th frame's value + } + + #endregion + #region Helpers private static DecodableStreamingDevice CreateStreamingDevice( diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 2ad62c4a..87913537 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -90,6 +90,31 @@ public class DaqifiStreamingDevice : DaqifiDevice, IStreamingDevice, INetworkCon /// private readonly TimestampGapDetector _gapDetector = new(); + /// + /// The maximum number of leading short-analog frames suppressed at stream start + /// (see ). Bounds the warmup-frame guard so a + /// genuinely short stream can never be withheld indefinitely. + /// + private const int MaxSuppressedWarmupFrames = 5; + + /// + /// True from the start of a streaming session until the first analog-bearing frame carrying + /// the full enabled-channel complement has been decoded. Guards the malformed warmup frame + /// the firmware emits at stream start (issue #351): its fast streaming encoder can emit a + /// leading frame with fewer analog values than the enabled channel mask, which would + /// otherwise reach every consumer as a partial (silently corrupting + /// first-value baselining, gap detection, and export). Such leading short frames are + /// suppressed — the per-channel decode is skipped, though the raw frame is still re-raised — + /// until the first full frame arrives, bounded by . + /// + private bool _awaitingFirstFullAnalogFrame; + + /// + /// Count of leading short-analog frames suppressed in the current session; capped by + /// . + /// + private int _suppressedWarmupFrameCount; + /// /// Gets a value indicating whether the device is currently streaming data. /// @@ -293,6 +318,11 @@ public void StartStreaming() _timestampProcessor.SetTimestampFrequency(StreamTimestampKey, TimestampFrequency); _gapDetector.Reset(); + // Arm the warmup-frame guard: the firmware's leading stream frame can carry a partial + // analog complement (issue #351) and must not reach consumers as a real sample. + _awaitingFirstFullAnalogFrame = true; + _suppressedWarmupFrameCount = 0; + IsStreaming = true; Send(ScpiMessageProducer.StartStreaming(StreamingFrequency)); } @@ -442,6 +472,31 @@ private void DecodeStreamFrame(DaqifiOutMessage message) return; } + // Snapshot channels once: the consumer thread that repopulates channels is the same + // thread that runs this decode, so the structure is stable for the duration of the call. + var channels = SnapshotChannels(); + + // Suppress the firmware's malformed warmup frame at stream start (issue #351): its fast + // streaming encoder can emit a leading analog-bearing frame with fewer values than the + // enabled channel mask. Dropping the whole frame here — before timestamp/gap processing, + // so it is a complete non-event and the next frame anchors the session clock — keeps a + // partial sample from reaching per-channel consumers and the live-sample stream. Only + // leading short frames are suppressed (mid-stream short frames stay best-effort mapped), + // bounded so a genuinely short stream is never withheld indefinitely. + if (_awaitingFirstFullAnalogFrame && (hasFloat || hasRawAnalog)) + { + var analogValueCount = hasFloat ? message.AnalogInDataFloat.Count : message.AnalogInData.Count; + var enabledAnalogCount = CountEnabledAnalogChannels(channels); + if (enabledAnalogCount > 0 && analogValueCount < enabledAnalogCount + && _suppressedWarmupFrameCount < MaxSuppressedWarmupFrames) + { + _suppressedWarmupFrameCount++; + return; + } + + _awaitingFirstFullAnalogFrame = false; + } + // Reconstruct a host timestamp from the device tick counter (rollover-aware) and carry // the raw device tick value through to each decoded sample. var deviceTimestamp = message.MsgTimeStamp; @@ -458,10 +513,6 @@ private void DecodeStreamFrame(DaqifiOutMessage message) hostTimestamp, timestampResult.SecondsBetweenMessages, deviceTimestamp)); } - // Snapshot channels once: the consumer thread that repopulates channels is the same - // thread that runs this decode, so the structure is stable for the duration of the call. - var channels = SnapshotChannels(); - if (hasFloat || hasRawAnalog) { DecodeAnalog(message, channels, hostTimestamp, deviceTimestamp, hasFloat); @@ -502,6 +553,19 @@ private void RaiseGapDetected(TimestampGapEventArgs args) /// USB firmware streams pre-scaled floats (used directly); WiFi firmware streams raw ADC /// counts (scaled per channel via ). /// + private static int CountEnabledAnalogChannels(IReadOnlyList channels) + { + var count = 0; + foreach (var channel in channels) + { + if (channel.IsEnabled && channel is IAnalogChannel) + { + count++; + } + } + return count; + } + private static void DecodeAnalog( DaqifiOutMessage message, IReadOnlyList channels, From 565a9bab55a63eb38f63a84adfab5b6b446f7955 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Sun, 19 Jul 2026 09:26:55 -0600 Subject: [PATCH 2/3] fix(device): arm warmup guard only when analog enabled at stream start (#351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Qodo review: the warmup guard was armed unconditionally in StartStreaming but only cleared on an analog-bearing frame, so a digital-only start left it armed. If analog channels were enabled mid-stream, the first short analog frames could be suppressed far from session start. Arm the guard only when >=1 analog channel is enabled at StartStreaming — the reproduced failure mode (#351) is a leading partial-analog frame at the start of an analog stream. A digital-only start is now disarmed, so a mid-stream analog short frame is best-effort mapped rather than suppressed. Adds a test covering digital-only start -> mid-stream analog enable -> short frame not suppressed. Full suite green net9+net10; bench re-validated (first analog frame still full). Co-Authored-By: Claude Opus 4.8 --- .../DaqifiStreamingDeviceDecodeTests.cs | 32 +++++++++++++++++++ .../Device/DaqifiStreamingDevice.cs | 15 ++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs index 9bd55b2e..b08a23a6 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs @@ -666,6 +666,38 @@ public void Decode_WarmupGuardReArmsForEachSession() Assert.Equal(2.0, ai1.ActiveSample!.Value); } + [Fact] + public void Decode_DigitalOnlyStart_ThenAnalogEnabledMidStream_ShortFrameNotSuppressed() + { + // The warmup guard is armed only when analog channels are enabled at StartStreaming. A + // session that starts digital-only leaves it disarmed, so a short analog frame arriving + // after analog is enabled mid-stream is best-effort mapped, not treated as a leading + // warmup frame far from session start. + var device = CreateStreamingDevice(analogCount: 2, digitalCount: 2); + var ai0 = AnalogChannel(device, 0); + var ai1 = AnalogChannel(device, 1); + var dio0 = DigitalChannel(device, 0); + dio0.IsEnabled = true; // digital-only at start + device.StartStreaming(); + + // A digital frame streams normally. + var digital = new DaqifiOutMessage { MsgTimeStamp = 1 }; + digital.DigitalData = ByteString.CopyFrom(new byte[] { 0b1 }); + device.InvokeStreamMessage(digital); + Assert.Equal(1.0, dio0.ActiveSample!.Value); + + // Enable analog mid-stream, then a short analog frame arrives (guard was never armed). + device.EnableChannels(new[] { ai0, ai1 }); + var shortAnalog = new DaqifiOutMessage { MsgTimeStamp = 2 }; + shortAnalog.AnalogInDataFloat.Add(7f); // one value for two enabled channels + + var ex = Record.Exception(() => device.InvokeStreamMessage(shortAnalog)); + + Assert.Null(ex); + Assert.Equal(7.0, ai0.ActiveSample!.Value); // not suppressed — best-effort mapped + Assert.Null(ai1.ActiveSample); + } + [Fact] public void Decode_PersistentShortFrames_ReleasedAfterCap() { diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 87913537..cd99a5d0 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -98,8 +98,9 @@ public class DaqifiStreamingDevice : DaqifiDevice, IStreamingDevice, INetworkCon private const int MaxSuppressedWarmupFrames = 5; /// - /// True from the start of a streaming session until the first analog-bearing frame carrying - /// the full enabled-channel complement has been decoded. Guards the malformed warmup frame + /// True from the start of a streaming session that begins with analog channels enabled, + /// until the first analog-bearing frame carrying the full enabled-channel complement has + /// been decoded (disarmed for a digital-only start). Guards the malformed warmup frame /// the firmware emits at stream start (issue #351): its fast streaming encoder can emit a /// leading frame with fewer analog values than the enabled channel mask, which would /// otherwise reach every consumer as a partial (silently corrupting @@ -318,9 +319,13 @@ public void StartStreaming() _timestampProcessor.SetTimestampFrequency(StreamTimestampKey, TimestampFrequency); _gapDetector.Reset(); - // Arm the warmup-frame guard: the firmware's leading stream frame can carry a partial - // analog complement (issue #351) and must not reach consumers as a real sample. - _awaitingFirstFullAnalogFrame = true; + // Arm the warmup-frame guard only when analog channels are enabled at stream start — + // the reproduced failure mode (issue #351) is the firmware's leading partial-analog + // frame at the start of an *analog* stream. A digital-only start needs no guard; leaving + // it disarmed there also avoids suppressing short analog frames that could arrive far + // from session start if analog channels are enabled mid-stream (a scenario with no + // observed warmup frame). + _awaitingFirstFullAnalogFrame = CountEnabledAnalogChannels(SnapshotChannels()) > 0; _suppressedWarmupFrameCount = 0; IsStreaming = true; From d94a274762d53f8c05a042c72e15a6127df05557 Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Sun, 19 Jul 2026 09:47:51 -0600 Subject: [PATCH 3/3] fix(device): warmup suppression drops only analog, keeps digital payload (#351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Qodo review: the fast streaming encoder packs analog+digital into one frame (issue evidence: "analog=[1] digital=00-04"), but suppressing a leading short-analog warmup frame via early-return also dropped its valid digital payload and skipped timestamp/gap processing. Suppress only the analog decode for warmup frames: still run timestamp/gap processing and DecodeDigital. The warmup frame's timestamp is a normal one sample period, so anchoring the session clock on it is correct — digital state/edges in a combined frame are no longer lost. Reworked the anchoring test (warmup now anchors, verified no false gap on steady cadence) and added a combined analog+digital warmup test (analog suppressed, digital preserved). Full suite green net9+net10 (1732); bench re-validated (analog-at-start still drops leading partial frame). Co-Authored-By: Claude Opus 4.8 --- .../DaqifiStreamingDeviceDecodeTests.cs | 42 ++++++++++++++++--- .../Device/DaqifiStreamingDevice.cs | 24 ++++++----- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs index b08a23a6..404abc88 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceDecodeTests.cs @@ -547,10 +547,10 @@ public void Decode_SuppressesMalformedFirstFrame_ThenEmitsFullFrame() } [Fact] - public void Decode_SuppressedWarmupFrame_DoesNotAnchorSessionClock() + public void Decode_WarmupFrameThenSteadyCadence_NoFalseGap() { - // A suppressed warmup frame must be a complete non-event: the *next* frame anchors the - // timestamp baseline, so no spurious gap is reported from the warmup-to-first delta. + // The warmup frame's timestamp is normal (one sample period before the next frame), so it + // anchors the session clock correctly — a steady cadence after it reports no false gap. var device = CreateStreamingDevice(analogCount: 2); AnalogChannel(device, 0).IsEnabled = true; AnalogChannel(device, 1).IsEnabled = true; @@ -559,12 +559,12 @@ public void Decode_SuppressedWarmupFrame_DoesNotAnchorSessionClock() var gaps = new List(); device.GapDetected += (_, e) => gaps.Add(e); + // Warmup frame (partial analog), then a steady one-period cadence. var warmup = new DaqifiOutMessage { MsgTimeStamp = 1000 }; warmup.AnalogInDataFloat.Add(0.1f); device.InvokeStreamMessage(warmup); - // Steady full-frame cadence after the warmup frame. - for (uint ts = 100000; ts <= 105000; ts += 1000) + for (uint ts = 2000; ts <= 12000; ts += 1000) { var frame = new DaqifiOutMessage { MsgTimeStamp = ts }; frame.AnalogInDataFloat.Add(1f); @@ -572,10 +572,40 @@ public void Decode_SuppressedWarmupFrame_DoesNotAnchorSessionClock() device.InvokeStreamMessage(frame); } - // Had the warmup frame (ts=1000) anchored the clock, the jump to ts=100000 would trip a gap. Assert.Empty(gaps); } + [Fact] + public void Decode_CombinedWarmupFrame_SuppressesAnalogButKeepsDigital() + { + // The firmware's fast encoder packs analog+digital into one frame, so the warmup frame + // carries a valid digital payload alongside its partial analog values (issue #351 evidence: + // "analog=[1] digital=00-04"). Only the malformed analog is dropped; digital is preserved. + var device = CreateStreamingDevice(analogCount: 2, digitalCount: 2); + var ai0 = AnalogChannel(device, 0); + var ai1 = AnalogChannel(device, 1); + var dio0 = DigitalChannel(device, 0); + var dio1 = DigitalChannel(device, 1); + ai0.IsEnabled = true; + ai1.IsEnabled = true; + dio0.IsEnabled = true; + dio1.IsEnabled = true; + device.StartStreaming(); + + var warmup = new DaqifiOutMessage { MsgTimeStamp = 1000 }; + warmup.AnalogInDataFloat.Add(0.1f); // partial analog: 1 value for 2 enabled channels + warmup.DigitalData = ByteString.CopyFrom(new byte[] { 0b10 }); // DIO0 low, DIO1 high + + device.InvokeStreamMessage(warmup); + + // Analog values suppressed... + Assert.Null(ai0.ActiveSample); + Assert.Null(ai1.ActiveSample); + // ...but the digital payload in the same frame is still decoded. + Assert.Equal(0.0, dio0.ActiveSample!.Value); + Assert.Equal(1.0, dio1.ActiveSample!.Value); + } + [Fact] public void Decode_WarmupFrame_StillReRaisesRawMessage() { diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index cd99a5d0..36896f8c 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -104,9 +104,10 @@ public class DaqifiStreamingDevice : DaqifiDevice, IStreamingDevice, INetworkCon /// the firmware emits at stream start (issue #351): its fast streaming encoder can emit a /// leading frame with fewer analog values than the enabled channel mask, which would /// otherwise reach every consumer as a partial (silently corrupting - /// first-value baselining, gap detection, and export). Such leading short frames are - /// suppressed — the per-channel decode is skipped, though the raw frame is still re-raised — - /// until the first full frame arrives, bounded by . + /// first-value baselining, gap detection, and export). For such leading short frames only + /// the malformed analog decode is skipped — a combined frame's digital payload is still + /// decoded and the raw frame is still re-raised — until the first full frame arrives, + /// bounded by . /// private bool _awaitingFirstFullAnalogFrame; @@ -483,11 +484,12 @@ private void DecodeStreamFrame(DaqifiOutMessage message) // Suppress the firmware's malformed warmup frame at stream start (issue #351): its fast // streaming encoder can emit a leading analog-bearing frame with fewer values than the - // enabled channel mask. Dropping the whole frame here — before timestamp/gap processing, - // so it is a complete non-event and the next frame anchors the session clock — keeps a - // partial sample from reaching per-channel consumers and the live-sample stream. Only + // enabled channel mask. Only the malformed *analog* values are withheld — a combined + // frame's digital payload is still decoded, and the frame's (normal one-period) + // timestamp still anchors the session clock, so digital state/edges are not lost. Only // leading short frames are suppressed (mid-stream short frames stay best-effort mapped), // bounded so a genuinely short stream is never withheld indefinitely. + var suppressWarmupAnalog = false; if (_awaitingFirstFullAnalogFrame && (hasFloat || hasRawAnalog)) { var analogValueCount = hasFloat ? message.AnalogInDataFloat.Count : message.AnalogInData.Count; @@ -496,10 +498,12 @@ private void DecodeStreamFrame(DaqifiOutMessage message) && _suppressedWarmupFrameCount < MaxSuppressedWarmupFrames) { _suppressedWarmupFrameCount++; - return; + suppressWarmupAnalog = true; + } + else + { + _awaitingFirstFullAnalogFrame = false; } - - _awaitingFirstFullAnalogFrame = false; } // Reconstruct a host timestamp from the device tick counter (rollover-aware) and carry @@ -518,7 +522,7 @@ private void DecodeStreamFrame(DaqifiOutMessage message) hostTimestamp, timestampResult.SecondsBetweenMessages, deviceTimestamp)); } - if (hasFloat || hasRawAnalog) + if ((hasFloat || hasRawAnalog) && !suppressWarmupAnalog) { DecodeAnalog(message, channels, hostTimestamp, deviceTimestamp, hasFloat); }