From 191516860671c790ae6ce81904ad68db1539804a Mon Sep 17 00:00:00 2001 From: Tyler Kron Date: Wed, 5 Aug 2026 18:54:10 -0600 Subject: [PATCH] refactor(device): extract raw-command session interpretation into a collaborator (part of #344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #379 session tracking — recognizing a streaming session driven through the raw Send path rather than the typed API — sat inline in DaqifiStreamingDevice as three private methods and three private constants, mixing the decision (what does this command text mean?) with the effects (re-anchor the session, flip the flag, assign channel state under the device's lock). The decision is pure text-and-arithmetic, so it moves to SessionCommandInterpreter: a command plus the sampling ceiling in, a typed SessionCommandEffect out. The effects stay on the device, which is the only thing that owns them. No public API change, no behavior change. The ceiling is still read exactly once per tracked command and handed in, preserving the property that tracking can never throw out of a Send whose command has already reached the device. Pure refactor: DaqifiStreamingDevice.cs drops from 1385 to 1310 lines. --- .../SessionCommandInterpreterTests.cs | 179 ++++++++++++++ .../Device/DaqifiStreamingDevice.cs | 169 ++++--------- .../Internal/SessionCommandInterpreter.cs | 227 ++++++++++++++++++ 3 files changed, 453 insertions(+), 122 deletions(-) create mode 100644 src/Daqifi.Core.Tests/Device/Internal/SessionCommandInterpreterTests.cs create mode 100644 src/Daqifi.Core/Device/Internal/SessionCommandInterpreter.cs diff --git a/src/Daqifi.Core.Tests/Device/Internal/SessionCommandInterpreterTests.cs b/src/Daqifi.Core.Tests/Device/Internal/SessionCommandInterpreterTests.cs new file mode 100644 index 0000000..65c8061 --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Internal/SessionCommandInterpreterTests.cs @@ -0,0 +1,179 @@ +using Daqifi.Core.Communication.Producers; +using Daqifi.Core.Device.Internal; +using Xunit; + +namespace Daqifi.Core.Tests.Device.Internal; + +/// +/// Unit tests for , which reads a command that has already +/// been sent and decides what it means for the streaming session (issue #379). These pin the +/// decision itself; DeviceReconnectTests pins the effects the device applies from it. +/// +public class SessionCommandInterpreterTests +{ + private const int MaxSamplingRate = 1000; + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ABlankCommand_MeansNothing(string? command) + { + var effect = SessionCommandInterpreter.Interpret(command, MaxSamplingRate); + + Assert.Equal(SessionCommandEffectKind.None, effect.Kind); + } + + [Theory] + [InlineData("SYSTem:REboot")] + [InlineData("DIO:PORt:ENAble 1")] + [InlineData("SYSTem:SYSInfoPB?")] + public void ACommandThatSaysNothingAboutTheSession_PassesThrough(string command) + { + // The global DIO enable is deliberately in this group: it is one switch for the whole port + // rather than a per-channel mask, so it carries no information about which digital channels + // were wanted and none is inferred. + var effect = SessionCommandInterpreter.Interpret(command, MaxSamplingRate); + + Assert.Equal(SessionCommandEffectKind.None, effect.Kind); + } + + [Fact] + public void AStopCommand_EndsTheSession() + { + var effect = SessionCommandInterpreter.Interpret("SYSTem:StopStreamData", MaxSamplingRate); + + Assert.Equal(SessionCommandEffectKind.StopStreaming, effect.Kind); + } + + [Fact] + public void TheProducersOwnCommands_AreTheOnesRecognized() + { + // The constants are duplicated from the producer's output rather than derived from it, so + // this is the guard that keeps the two from drifting apart silently. + var start = SessionCommandInterpreter.Interpret( + ScpiMessageProducer.StartStreaming(250).Data, MaxSamplingRate); + var stop = SessionCommandInterpreter.Interpret( + ScpiMessageProducer.StopStreaming.Data, MaxSamplingRate); + var enable = SessionCommandInterpreter.Interpret( + ScpiMessageProducer.EnableAdcChannels("10").Data, MaxSamplingRate); + + Assert.Equal(SessionCommandEffectKind.StartStreaming, start.Kind); + Assert.Equal(250, start.StreamingFrequency); + Assert.Equal(SessionCommandEffectKind.StopStreaming, stop.Kind); + Assert.Equal(SessionCommandEffectKind.SetAdcEnableMask, enable.Kind); + Assert.Equal(10u, enable.AdcEnableMask); + } + + [Theory] + [InlineData("systEM:startstreamdata 100")] + [InlineData("SYSTEM:STARTSTREAMDATA 100")] + public void CommandMatchingIsCaseInsensitive(string command) + { + // SCPI's short/long forms differ only in case, so a caller writing the long form must be + // recognized exactly like the producer's mixed-case output. + var effect = SessionCommandInterpreter.Interpret(command, MaxSamplingRate); + + Assert.Equal(SessionCommandEffectKind.StartStreaming, effect.Kind); + Assert.Equal(100, effect.StreamingFrequency); + } + + [Fact] + public void SurroundingWhitespace_DoesNotHideACommand() + { + var effect = SessionCommandInterpreter.Interpret(" SYSTem:StartStreamData 100 ", MaxSamplingRate); + + Assert.Equal(SessionCommandEffectKind.StartStreaming, effect.Kind); + Assert.Equal(100, effect.StreamingFrequency); + } + + [Theory] + [InlineData("SYSTem:StartStreamData")] // no argument at all + [InlineData("SYSTem:StartStreamData ")] // argument present but empty + [InlineData("SYSTem:StartStreamData abc")] // not a number + [InlineData("SYSTem:StartStreamData 100 extra")] // trailing junk + [InlineData("SYSTem:StartStreamData 0")] // below the usable range + [InlineData("SYSTem:StartStreamData -5")] // negative + [InlineData("SYSTem:StartStreamData 99999999")] // beyond the device's sampling rate + public void AStartCommandWithAnUnusableRate_IsNotAStart(string command) + { + // Reported as its own kind rather than as None so the caller can trace the rejection, but + // carrying no rate: marking a session started here would leave the streaming frequency + // holding a rate from an earlier session, which a reconnect would then faithfully restore. + var effect = SessionCommandInterpreter.Interpret(command, MaxSamplingRate); + + Assert.Equal(SessionCommandEffectKind.UnusableStreamingStart, effect.Kind); + Assert.Equal(0, effect.StreamingFrequency); + } + + [Fact] + public void ARejectedRate_IsReportedAsItWasWritten() + { + var effect = SessionCommandInterpreter.Interpret("SYSTem:StartStreamData not-a-rate ", MaxSamplingRate); + + Assert.Equal("not-a-rate", effect.RejectedRate); + } + + [Fact] + public void TheCeilingIsInclusive() + { + var atCeiling = SessionCommandInterpreter.Interpret("SYSTem:StartStreamData 1000", MaxSamplingRate); + var pastCeiling = SessionCommandInterpreter.Interpret("SYSTem:StartStreamData 1001", MaxSamplingRate); + + Assert.Equal(SessionCommandEffectKind.StartStreaming, atCeiling.Kind); + Assert.Equal(1000, atCeiling.StreamingFrequency); + Assert.Equal(SessionCommandEffectKind.UnusableStreamingStart, pastCeiling.Kind); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void AnUnusableCeiling_FallsBackToOneRatherThanRejectingEverything(int maxSamplingRate) + { + // MaxSamplingRate is a mutable, unvalidated public property. An uninitialized value must not + // produce an impossible range like "1..0" that turns every rate into a rejection. + var atFloor = SessionCommandInterpreter.Interpret("SYSTem:StartStreamData 1", maxSamplingRate); + var aboveFloor = SessionCommandInterpreter.Interpret("SYSTem:StartStreamData 2", maxSamplingRate); + + Assert.Equal(SessionCommandEffectKind.StartStreaming, atFloor.Kind); + Assert.Equal(1, atFloor.StreamingFrequency); + Assert.Equal(SessionCommandEffectKind.UnusableStreamingStart, aboveFloor.Kind); + } + + [Fact] + public void StopIsNotMistakenForStart() + { + // Both commands share the "SYSTem:St" prefix, so the order the two are tested in is load + // bearing rather than incidental. + var effect = SessionCommandInterpreter.Interpret("SYSTem:StopStreamData", MaxSamplingRate); + + Assert.Equal(SessionCommandEffectKind.StopStreaming, effect.Kind); + } + + [Theory] + [InlineData("ENAble:VOLTage:DC 0", 0u)] + [InlineData("ENAble:VOLTage:DC 5", 5u)] + [InlineData("ENAble:VOLTage:DC 65535 ", 65535u)] + [InlineData("ENAble:VOLTage:DC 4294967295", uint.MaxValue)] + public void AnAdcEnableCommand_CarriesItsMask(string command, uint expected) + { + var effect = SessionCommandInterpreter.Interpret(command, MaxSamplingRate); + + Assert.Equal(SessionCommandEffectKind.SetAdcEnableMask, effect.Kind); + Assert.Equal(expected, effect.AdcEnableMask); + } + + [Theory] + [InlineData("ENAble:VOLTage:DC")] // no argument + [InlineData("ENAble:VOLTage:DC abc")] // not a number + [InlineData("ENAble:VOLTage:DC -1")] // negative + [InlineData("ENAble:VOLTage:DC 4294967296")] // past uint + public void AnUnparseableMask_ChangesNothing(string command) + { + // A mask that cannot be read is not evidence that no channels are enabled — clearing the + // set from it would be inventing a state the device never reported. + var effect = SessionCommandInterpreter.Interpret(command, MaxSamplingRate); + + Assert.Equal(SessionCommandEffectKind.None, effect.Kind); + } +} diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 5766b3d..d41a4d0 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -12,7 +12,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Globalization; using System.IO; using System.Linq; using System.Net; @@ -360,15 +359,6 @@ public void StopStreaming() #region Session state tracking for commands sent directly (issue #379) - /// The command emits. - private const string StartStreamingCommand = "SYSTem:StartStreamData"; - - /// The command emits. - private const string StopStreamingCommand = "SYSTem:StopStreamData"; - - /// The command emits. - private const string EnableAdcChannelsCommand = "ENAble:VOLTage:DC"; - /// /// Sends a command, and keeps this device's view of the streaming session in step with it. /// @@ -378,21 +368,13 @@ public void StopStreaming() /// example CLI does the whole job that way — but a session driven through it used to be /// completely invisible to this class: stayed false while /// data poured in, and the enabled-channel set stayed empty. That mattered once reconnect - /// arrived (issue #379). A physical cable pull on the bench recovered the link and then - /// reported StreamingResumed: false, because as far as Core was concerned nothing had - /// ever been streaming — while re-initialization had in fact just stopped the stream that - /// was running. The session looked restored and was not. + /// arrived (issue #379). reads the commands that + /// define a session; this method applies what they imply, so the same state is updated that + /// / and + /// would have set. /// /// - /// So the two commands that define a streaming session are recognized here regardless of - /// which API produced them, and the same state is updated that - /// / and - /// would have set. This is the same principle as #409, where - /// analog IsEnabled is resynced from the device's own reported mask: what Core - /// believes about a session has to track what is actually true of it. - /// - /// - /// Only these commands are interpreted, and only after the send itself has succeeded. + /// Only those commands are interpreted, and only after the send itself has succeeded. /// Everything else passes through untouched. /// /// @@ -420,13 +402,6 @@ public void StopStreaming() /// /// /// - /// Deliberately outside it. The global DIO enable is one switch for the whole port - /// rather than a per-channel mask, so a raw DIO:PORt:ENAble carries no information - /// about which digital channels were wanted and none is inferred. Argument validation - /// is not replayed either: by the time a command is seen here it has already gone to the - /// device, and the device is the authority on whether it accepted it. - /// - /// /// Running after the send is safe rather than merely convenient. A string command is handed /// to the background producer, so it has not reached the wire when this runs, and the device /// cannot answer a command it has not received; on the producer-less path that writes @@ -451,100 +426,55 @@ public override void Send(IOutboundMessage message) /// /// Updates the streaming-session view from a command that has just been sent. /// + /// + /// The sampling ceiling is read exactly once and handed to the interpreter, which validates + /// the rate against it; the value that comes back is then assigned to the backing field + /// rather than through the validating setter. That setter + /// re-reads , which is a mutable public + /// property — so validating against one read and assigning through a setter that takes + /// another would let a concurrent capabilities update throw out of a + /// whose command has already gone to the device. Tracking a command must never be able to + /// fail the send that carried it. + /// private void TrackSessionCommand(string? command) { - if (string.IsNullOrWhiteSpace(command)) - { - return; - } + var effect = SessionCommandInterpreter.Interpret(command, Metadata.Capabilities.MaxSamplingRate); - var trimmed = command.Trim(); - - if (trimmed.StartsWith(StopStreamingCommand, StringComparison.OrdinalIgnoreCase)) - { - IsStreaming = false; - return; - } - - if (trimmed.StartsWith(StartStreamingCommand, StringComparison.OrdinalIgnoreCase)) - { - TrackStreamingStart(trimmed.AsSpan(StartStreamingCommand.Length)); - return; - } - - if (trimmed.StartsWith(EnableAdcChannelsCommand, StringComparison.OrdinalIgnoreCase)) + switch (effect.Kind) { - TrackAdcEnableMask(trimmed.AsSpan(EnableAdcChannelsCommand.Length)); - } - } + case SessionCommandEffectKind.StopStreaming: + IsStreaming = false; + break; + + case SessionCommandEffectKind.StartStreaming: + // Frequency first: anything observing IsStreaming must never catch it true next + // to a rate belonging to a previous session. + _streamingFrequency = effect.StreamingFrequency; + + // A restart while already streaming is not a session boundary — the typed API + // cannot even express it (StartStreaming returns early) — so there is nothing to + // re-anchor and recording the new rate is all that is warranted. + if (!IsStreaming) + { + // A session is beginning, so it gets exactly the preparation StartStreaming + // would have given it. Ordering matches too: the state is ready before the + // flag flips. + BeginStreamingSession(); + IsStreaming = true; + } - /// - /// Records a start-streaming command, but only one carrying a rate this device can model. - /// - /// - /// - /// A command whose argument is missing, unparseable, or outside the device's sampling range - /// is not treated as the start of a session. Marking one as streaming anyway would be - /// wrong three times over: the firmware rejects such a command and does not start streaming, - /// so the flag would not describe the device; would be left - /// holding a rate from some earlier session, which a reconnect would then faithfully restore - /// — resuming at a rate nobody asked for is the silent-wrong-data failure this whole feature - /// exists to prevent; and a stale makes the next legitimate - /// a silent no-op, which is the same stale-flag trap that - /// issue #118 and the defensive stops scattered through the SD paths already guard against. - /// - /// - /// The existing state is left alone rather than cleared. A device already streaming at a - /// good rate goes on doing exactly that when the firmware rejects a malformed start, so - /// and both remain true of it; - /// forcing them off would swap one inaccuracy for another. - /// - /// - /// Because of this, is never true alongside a rate that was - /// not validated — so session restore has no "streaming at an unknown rate" case to decide - /// what to do about. The state it replays is always one that was really commanded. - /// - /// - private void TrackStreamingStart(ReadOnlySpan argument) - { - var rate = argument.Trim(); - - // One read of the ceiling, used for both the check and the assignment below. The public - // StreamingFrequency setter re-reads it and throws when it does not like the value, and - // MaxSamplingRate is a mutable public property — so validating against one read and - // then assigning through a setter that takes another would let a concurrent - // capabilities update throw out of a Send whose command has already gone to the device. - // Tracking a command must never be able to fail the send that carried it. - var maxSamplingRate = Math.Max(1, Metadata.Capabilities.MaxSamplingRate); - - if (!int.TryParse(rate, NumberStyles.Integer, CultureInfo.InvariantCulture, out var frequency) - || frequency < 1 - || frequency > maxSamplingRate) - { - SafeTrace( - $"[{nameof(TrackStreamingStart)}] Ignoring a start-streaming command with an unusable rate " - + $"('{rate.ToString()}'); the session state is unchanged."); - return; - } + break; - // Frequency first: anything observing IsStreaming must never catch it true next to a - // rate belonging to a previous session. Assigned to the backing field, not through the - // validating setter, for the reason above — the value has just been validated against - // the same rule. - _streamingFrequency = frequency; + case SessionCommandEffectKind.UnusableStreamingStart: + SafeTrace( + $"[{nameof(TrackSessionCommand)}] Ignoring a start-streaming command with an unusable rate " + + $"('{effect.RejectedRate}'); the session state is unchanged."); + break; - if (IsStreaming) - { - // A restart while already streaming. The typed API cannot even express this - // (StartStreaming returns early), so there is no session boundary to re-anchor at - // and no equivalence to preserve; recording the new rate is all that is warranted. - return; + case SessionCommandEffectKind.SetAdcEnableMask: + ApplyAdcEnableMask(effect.AdcEnableMask); + break; } - - // A session is beginning, so it gets exactly the preparation StartStreaming would have - // given it. Ordering matches too: the state is ready before the flag flips. - BeginStreamingSession(); - IsStreaming = true; } /// @@ -558,13 +488,8 @@ private void TrackStreamingStart(ReadOnlySpan argument) /// next status frame (#409) — that is the device's own view, and it outranks what was asked /// for. /// - private void TrackAdcEnableMask(ReadOnlySpan argument) + private void ApplyAdcEnableMask(uint mask) { - if (!uint.TryParse(argument.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var mask)) - { - return; - } - WithChannelsLock(() => { foreach (var channel in SnapshotChannels()) diff --git a/src/Daqifi.Core/Device/Internal/SessionCommandInterpreter.cs b/src/Daqifi.Core/Device/Internal/SessionCommandInterpreter.cs new file mode 100644 index 0000000..5850bb2 --- /dev/null +++ b/src/Daqifi.Core/Device/Internal/SessionCommandInterpreter.cs @@ -0,0 +1,227 @@ +using System; +using System.Globalization; + +#nullable enable + +namespace Daqifi.Core.Device.Internal +{ + /// + /// What a command that has just been sent means for the device's view of its streaming session. + /// + internal enum SessionCommandEffectKind + { + /// The command says nothing about the session; the device state is untouched. + None, + + /// The session has ended. + StopStreaming, + + /// + /// A session is starting, or a running one is changing rate, at + /// . + /// + StartStreaming, + + /// + /// A start-streaming command carrying a rate this device cannot model. Reported rather than + /// folded into so the caller can still trace it — see + /// for why it changes no state. + /// + UnusableStreamingStart, + + /// + /// An ADC enable bitmask was sent; carries it. + /// + SetAdcEnableMask, + } + + /// + /// The decision reached about one sent command, together + /// with the value it carries. + /// + internal readonly struct SessionCommandEffect + { + private SessionCommandEffect( + SessionCommandEffectKind kind, + int streamingFrequency, + uint adcEnableMask, + string? rejectedRate) + { + Kind = kind; + StreamingFrequency = streamingFrequency; + AdcEnableMask = adcEnableMask; + RejectedRate = rejectedRate; + } + + /// Gets what the command means for the session. + public SessionCommandEffectKind Kind { get; } + + /// + /// Gets the validated rate, in Hz, when is + /// . Zero otherwise. + /// + public int StreamingFrequency { get; } + + /// + /// Gets the bitmask when is + /// . Zero otherwise. + /// + public uint AdcEnableMask { get; } + + /// + /// Gets the rate argument as it was written, when is + /// . Null otherwise. Present + /// only so the rejection can be traced with the text that caused it. + /// + public string? RejectedRate { get; } + + /// The command carries nothing this device tracks. + public static SessionCommandEffect None { get; } = new(SessionCommandEffectKind.None, 0, 0, null); + + /// The session has ended. + public static SessionCommandEffect StopStreaming { get; } = new(SessionCommandEffectKind.StopStreaming, 0, 0, null); + + /// A session is running at Hz. + public static SessionCommandEffect StartStreaming(int frequency) => + new(SessionCommandEffectKind.StartStreaming, frequency, 0, null); + + /// A start command whose rate argument, , is unusable. + public static SessionCommandEffect UnusableStreamingStart(string rejectedRate) => + new(SessionCommandEffectKind.UnusableStreamingStart, 0, 0, rejectedRate); + + /// An ADC enable bitmask of was sent. + public static SessionCommandEffect SetAdcEnableMask(uint mask) => + new(SessionCommandEffectKind.SetAdcEnableMask, 0, mask, null); + } + + /// + /// Reads a SCPI command that has just been sent and decides what it means for the streaming + /// session, so a session driven through the raw path stays as + /// visible to Core as one driven through the typed API (issue #379). + /// + /// + /// + /// is public and is a perfectly ordinary way to drive a device + /// — the example CLI does the whole job that way — but a session driven through it used to be + /// completely invisible: stayed false + /// while data poured in, and the enabled-channel set stayed empty. That mattered once reconnect + /// arrived. A physical cable pull on the bench recovered the link and then reported + /// StreamingResumed: false, because as far as Core was concerned nothing had ever been + /// streaming — while re-initialization had in fact just stopped the stream that was running. + /// The session looked restored and was not. + /// + /// + /// So the commands that define a streaming session are recognized here regardless of which API + /// produced them, and the caller applies the same state the typed methods would have set. This + /// is the same principle as #409, where analog IsEnabled is resynced from the device's own + /// reported mask: what Core believes about a session has to track what is actually true of it. + /// + /// + /// Deciding is separated from applying on purpose. The decision is pure text-and-arithmetic and + /// is pinned directly by SessionCommandInterpreterTests; the effects it implies — + /// re-anchoring the session, flipping the flag, assigning channel state under the device's lock — + /// stay on the device, which is the only thing that owns them. + /// + /// + /// Deliberately outside the scope. The global DIO enable is one switch for the whole port + /// rather than a per-channel mask, so a raw DIO:PORt:ENAble carries no information about + /// which digital channels were wanted and none is inferred. Argument validation is not + /// replayed either: by the time a command is read here it has already gone to the device, and + /// the device is the authority on whether it accepted it. + /// + /// + internal static class SessionCommandInterpreter + { + /// The command emits. + internal const string StartStreamingCommand = "SYSTem:StartStreamData"; + + /// The command emits. + internal const string StopStreamingCommand = "SYSTem:StopStreamData"; + + /// The command emits. + internal const string EnableAdcChannelsCommand = "ENAble:VOLTage:DC"; + + /// + /// Decides what means for the streaming session. + /// + /// + /// + /// A start command whose argument is missing, unparseable, or outside the device's sampling + /// range comes back as rather + /// than as the start of a session. Treating one as a start would be wrong three times over: + /// the firmware rejects such a command and does not start streaming, so the flag would not + /// describe the device; the streaming frequency would be left holding a rate from some + /// earlier session, which a reconnect would then faithfully restore — resuming at a rate + /// nobody asked for is the silent-wrong-data failure this whole feature exists to prevent; + /// and a stale streaming flag makes the next legitimate + /// a silent no-op, which is the same trap + /// issue #118 and the defensive stops scattered through the SD paths already guard against. + /// + /// + /// The existing state is therefore left alone rather than cleared. A device already streaming + /// at a good rate goes on doing exactly that when the firmware rejects a malformed start, so + /// both its flag and its rate remain true of it; forcing them off would swap one inaccuracy + /// for another. Because of this, the device is never marked streaming alongside a rate that + /// was not validated — so session restore has no "streaming at an unknown rate" case to + /// decide what to do about. + /// + /// + /// The command text that was just sent; null or blank yields . + /// + /// The device's advertised maximum sampling rate. Passed in as a single read by the caller + /// rather than read here twice: it is a mutable public property, so validating against one + /// read and applying against another would let a concurrent capabilities update reject a + /// command that has already reached the device. Sanitized with a floor of 1 so an + /// uninitialized or invalid value (0 or negative) cannot produce an impossible range like + /// "1..0" that rejects every rate. + /// + public static SessionCommandEffect Interpret(string? command, int maxSamplingRate) + { + if (string.IsNullOrWhiteSpace(command)) + { + return SessionCommandEffect.None; + } + + var trimmed = command!.Trim(); + + if (trimmed.StartsWith(StopStreamingCommand, StringComparison.OrdinalIgnoreCase)) + { + return SessionCommandEffect.StopStreaming; + } + + if (trimmed.StartsWith(StartStreamingCommand, StringComparison.OrdinalIgnoreCase)) + { + return InterpretStreamingStart(trimmed.AsSpan(StartStreamingCommand.Length), maxSamplingRate); + } + + if (trimmed.StartsWith(EnableAdcChannelsCommand, StringComparison.OrdinalIgnoreCase)) + { + return InterpretAdcEnableMask(trimmed.AsSpan(EnableAdcChannelsCommand.Length)); + } + + return SessionCommandEffect.None; + } + + private static SessionCommandEffect InterpretStreamingStart(ReadOnlySpan argument, int maxSamplingRate) + { + var rate = argument.Trim(); + var ceiling = Math.Max(1, maxSamplingRate); + + if (!int.TryParse(rate, NumberStyles.Integer, CultureInfo.InvariantCulture, out var frequency) + || frequency < 1 + || frequency > ceiling) + { + return SessionCommandEffect.UnusableStreamingStart(rate.ToString()); + } + + return SessionCommandEffect.StartStreaming(frequency); + } + + private static SessionCommandEffect InterpretAdcEnableMask(ReadOnlySpan argument) + { + return uint.TryParse(argument.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var mask) + ? SessionCommandEffect.SetAdcEnableMask(mask) + : SessionCommandEffect.None; + } + } +}