diff --git a/docs/DEVICE_INTERFACES.md b/docs/DEVICE_INTERFACES.md index edf77e7e..08e91b3a 100644 --- a/docs/DEVICE_INTERFACES.md +++ b/docs/DEVICE_INTERFACES.md @@ -336,6 +336,54 @@ device.Send(ScpiMessageProducer.DisableDeviceEcho); device.Send(ScpiMessageProducer.SetProtobufStreamFormat); ``` +## Device Diagnostics + +`IDeviceDiagnostics` (implemented by `DaqifiStreamingDevice`) is a typed wrapper over the firmware's +logging and diagnostics SCPI surface — the system log, runtime log levels, SCPI command history, +error-queue depth, and streaming/memory performance counters. These values originate **on the +device**; this is not a client-side instrumentation framework. + +```csharp +using Daqifi.Core.Device.Diagnostics; + +using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760); + +// System log (reading the log also clears the device buffer). +IReadOnlyList log = await device.GetSystemLogAsync(); +foreach (var entry in log) Console.WriteLine(entry.Message); +await device.ClearSystemLogAsync(); + +// Runtime log levels (0 = None, 1 = Error, 2 = Info, 3 = Debug). The returned +// setting reflects the level actually applied, which a module's ceiling may cap. +LogLevelSetting applied = await device.SetLogLevelAsync("STREAM", 2); +Console.WriteLine($"{applied.Module}: {applied.Level} (ceiling {applied.Ceiling})"); + +// SCPI command history (newest first) and error-queue depth (non-destructive). +IReadOnlyList history = await device.GetCommandHistoryAsync(); +int queuedErrors = await device.GetSystemErrorCountAsync(); + +// Performance counters. Headline fields are typed (nullable when the running +// firmware doesn't emit them); the full set is available via Values. +StreamStats stream = await device.GetStreamStatsAsync(); +Console.WriteLine($"Samples: {stream.TotalSamplesStreamed}, dropped: {stream.QueueDroppedSamples}"); + +MemoryDiagnostics mem = await device.GetMemoryDiagnosticsAsync(); +Console.WriteLine($"Heap free: {mem.HeapFree}/{mem.HeapTotal}"); +foreach (var (key, value) in mem.Values) Console.WriteLine($"{key} = {value}"); +``` + +Notes: +- The `StreamStats`/`MemoryDiagnostics` parsers are **forward-compatible**: the device emits a set of + `Key=Value` lines whose membership grows between firmware versions, so every numeric pair is exposed + through `Values` and the typed properties return `null` for fields the running firmware omits. +- Each call runs as a text command (the protobuf consumer is paused for the exchange, like the SD and + LAN-chip queries). They do **not** stop streaming, so you can sample live counters — but parsing is + most reliable when the device is not actively streaming. Avoid issuing them concurrently. +- A `DeviceDiagnosticsException` (carrying `RawDeviceResponse`) is thrown when the device returns a + SCPI error or an unparseable response for the structured queries. +- `SYSTem:OS:Stats?` (FreeRTOS task stats) is intentionally **not** wrapped: it is commented out in the + current firmware. It can be added once the firmware re-enables it. + ## Thread Safety The `DaqifiDevice` message producer uses a background thread with a concurrent queue, making `Send()` calls thread-safe. Multiple threads can safely send commands: diff --git a/src/Daqifi.Core.Tests/Communication/Producers/ScpiMessageProducerTests.cs b/src/Daqifi.Core.Tests/Communication/Producers/ScpiMessageProducerTests.cs index d8710302..b8b93055 100644 --- a/src/Daqifi.Core.Tests/Communication/Producers/ScpiMessageProducerTests.cs +++ b/src/Daqifi.Core.Tests/Communication/Producers/ScpiMessageProducerTests.cs @@ -590,6 +590,100 @@ public void UpdateDacOutputs_ReturnsCorrectCommand() AssertMessageFormat(message); } + // --- Logging & diagnostics --- + + [Fact] + public void GetSystemLog_ReturnsCorrectCommand() + { + var message = ScpiMessageProducer.GetSystemLog; + Assert.Equal("SYSTem:LOG?", message.Data); + AssertMessageFormat(message); + } + + [Fact] + public void ClearSystemLog_ReturnsCorrectCommand() + { + var message = ScpiMessageProducer.ClearSystemLog; + Assert.Equal("SYSTem:LOG:CLEar", message.Data); + AssertMessageFormat(message); + } + + [Fact] + public void GetCommandHistory_ReturnsCorrectCommand() + { + var message = ScpiMessageProducer.GetCommandHistory; + Assert.Equal("SYSTem:LOG:CMDHistory?", message.Data); + AssertMessageFormat(message); + } + + [Fact] + public void TestSystemLog_ReturnsCorrectCommand() + { + var message = ScpiMessageProducer.TestSystemLog; + Assert.Equal("SYSTem:LOG:TEST", message.Data); + AssertMessageFormat(message); + } + + [Fact] + public void GetSystemErrorCount_ReturnsCorrectCommand() + { + var message = ScpiMessageProducer.GetSystemErrorCount; + Assert.Equal("SYSTem:ERRor:COUNt?", message.Data); + AssertMessageFormat(message); + } + + [Fact] + public void GetStreamStats_ReturnsCorrectCommand() + { + var message = ScpiMessageProducer.GetStreamStats; + Assert.Equal("SYSTem:STReam:STATS?", message.Data); + AssertMessageFormat(message); + } + + [Fact] + public void GetMemoryDiagnostics_ReturnsCorrectCommand() + { + var message = ScpiMessageProducer.GetMemoryDiagnostics; + Assert.Equal("SYSTem:MEMory:FREE?", message.Data); + AssertMessageFormat(message); + } + + [Fact] + public void SetLogLevel_FormatsModuleAndLevel() + { + var message = ScpiMessageProducer.SetLogLevel("STREAM", 2); + Assert.Equal("SYSTem:LOG:LEVel STREAM,2", message.Data); + AssertMessageFormat(message); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void SetLogLevel_WithEmptyModule_Throws(string? module) + { + Assert.Throws(() => ScpiMessageProducer.SetLogLevel(module!, 1)); + } + + [Theory] + [InlineData("STREAM,extra")] + [InlineData("a b")] + [InlineData("a;b")] + [InlineData("a\"b")] + [InlineData("a\nb")] + public void SetLogLevel_WithInjectionChars_Throws(string module) + { + Assert.Throws(() => ScpiMessageProducer.SetLogLevel(module, 1)); + } + + [Theory] + [InlineData(-1)] + [InlineData(4)] + public void SetLogLevel_WithLevelOutOfRange_Throws(int level) + { + Assert.Throws(() => ScpiMessageProducer.SetLogLevel("STREAM", level)); + } + private static void AssertMessageFormat(IOutboundMessage message) { var bytes = message.GetBytes(); diff --git a/src/Daqifi.Core.Tests/Device/Diagnostics/CommandHistoryParserTests.cs b/src/Daqifi.Core.Tests/Device/Diagnostics/CommandHistoryParserTests.cs new file mode 100644 index 00000000..57a7e96b --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Diagnostics/CommandHistoryParserTests.cs @@ -0,0 +1,67 @@ +using System; +using Daqifi.Core.Device.Diagnostics; + +namespace Daqifi.Core.Tests.Device.Diagnostics; + +public class CommandHistoryParserTests +{ + [Fact] + public void Parse_StripsHeaderAndNumericPrefix() + { + // Matches the SYSTem:LOG:CMDHistory? format: header + ": " lines. + var lines = new[] + { + "Last 3 commands:", + "3: SYSTem:LOG:TEST", + "2: SYSTem:STReam:STATS?", + "1: SYSTem:MEMory:FREE?", + }; + + var commands = CommandHistoryParser.Parse(lines); + + Assert.Equal(new[] + { + "SYSTem:LOG:TEST", + "SYSTem:STReam:STATS?", + "SYSTem:MEMory:FREE?", + }, commands); + } + + [Fact] + public void Parse_PreservesColonsWithinCommand() + { + var lines = new[] { "Last 1 commands:", "1: SYSTem:LOG:LEVel STREAM,2" }; + + var commands = CommandHistoryParser.Parse(lines); + + Assert.Equal(new[] { "SYSTem:LOG:LEVel STREAM,2" }, commands); + } + + [Fact] + public void Parse_WhenNoHistoryMarker_ReturnsEmpty() + { + Assert.Empty(CommandHistoryParser.Parse(new[] { "No command history" })); + } + + [Fact] + public void Parse_TrimsLineEndings() + { + var lines = new[] { "Last 1 commands:\r", "1: *IDN?\r" }; + + var commands = CommandHistoryParser.Parse(lines); + + Assert.Equal(new[] { "*IDN?" }, commands); + } + + [Fact] + public void Parse_WhenEmpty_ReturnsEmpty() + { + Assert.Empty(CommandHistoryParser.Parse(Array.Empty())); + } + + [Fact] + public void Parse_WhenNull_Throws() + { + Assert.Throws(() => CommandHistoryParser.Parse(null!)); + } +} diff --git a/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs b/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs new file mode 100644 index 00000000..714a3322 --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs @@ -0,0 +1,326 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading; +using System.Threading.Tasks; +using Daqifi.Core.Communication.Messages; +using Daqifi.Core.Device; +using Daqifi.Core.Device.Diagnostics; + +namespace Daqifi.Core.Tests.Device.Diagnostics; + +public class DeviceDiagnosticsTests +{ + [Fact] + public async Task GetSystemLogAsync_WhenDisconnected_Throws() + { + var device = new TestableDiagnosticsDevice("TestDevice"); + + await Assert.ThrowsAsync(() => device.GetSystemLogAsync()); + } + + [Fact] + public async Task GetSystemLogAsync_SendsCommandAndParsesEntries() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "Test log message 1", "Test info message" }, + }; + device.Connect(); + + var entries = await device.GetSystemLogAsync(); + + Assert.Contains("SYSTem:LOG?", device.SentCommands); + Assert.Equal(2, entries.Count); + Assert.Equal("Test log message 1", entries[0].Message); + } + + [Fact] + public async Task GetSystemLogAsync_WhenBufferEmpty_ReturnsEmpty() + { + // No lines = genuinely empty buffer (firmware writes nothing); must not throw. + var device = new TestableDiagnosticsDevice("TestDevice"); + device.Connect(); + + Assert.Empty(await device.GetSystemLogAsync()); + } + + [Fact] + public async Task GetSystemLogAsync_WhenErrorOnlyResponse_Throws() + { + // An error-only response must not masquerade as an empty log. + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "**ERROR: -113,\"Undefined header\"" }, + }; + device.Connect(); + + var ex = await Assert.ThrowsAsync(() => device.GetSystemLogAsync()); + Assert.NotEmpty(ex.RawDeviceResponse); + } + + [Fact] + public async Task ClearSystemLogAsync_SendsCommand() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "Log cleared" }, + }; + device.Connect(); + + await device.ClearSystemLogAsync(); + + Assert.Contains("SYSTem:LOG:CLEar", device.SentCommands); + } + + [Fact] + public async Task ClearSystemLogAsync_WhenErrorOnlyResponse_Throws() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "**ERROR: -113,\"Undefined header\"" }, + }; + device.Connect(); + + await Assert.ThrowsAsync(() => device.ClearSystemLogAsync()); + } + + [Fact] + public async Task ClearSystemLogAsync_WhenDisconnected_Throws() + { + var device = new TestableDiagnosticsDevice("TestDevice"); + + await Assert.ThrowsAsync(() => device.ClearSystemLogAsync()); + } + + [Fact] + public async Task SetLogLevelAsync_SendsCommandAndParsesEcho() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "STREAM: 2 (ceiling 3)" }, + }; + device.Connect(); + + var setting = await device.SetLogLevelAsync("STREAM", 2); + + Assert.Contains("SYSTem:LOG:LEVel STREAM,2", device.SentCommands); + Assert.Equal("STREAM", setting.Module); + Assert.Equal(2, setting.Level); + Assert.Equal(3, setting.Ceiling); + } + + [Fact] + public async Task SetLogLevelAsync_WhenDeviceReturnsScpiError_ThrowsDiagnosticsException() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "**ERROR: -224,\"Illegal parameter value\"" }, + }; + device.Connect(); + + var ex = await Assert.ThrowsAsync( + () => device.SetLogLevelAsync("STREAM", 2)); + Assert.NotEmpty(ex.RawDeviceResponse); + } + + [Fact] + public async Task SetLogLevelAsync_ValidatesArgumentsBeforeConnectionCheck() + { + // Disconnected device + bad module must surface ArgumentException (misuse), + // not InvalidOperationException (state), matching other setters. + var device = new TestableDiagnosticsDevice("TestDevice"); + + await Assert.ThrowsAsync(() => device.SetLogLevelAsync("", 1)); + await Assert.ThrowsAsync(() => device.SetLogLevelAsync("STREAM", 9)); + } + + [Fact] + public async Task GetCommandHistoryAsync_SendsCommandAndParsesCommands() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "Last 2 commands:", "2: *IDN?", "1: SYSTem:LOG:TEST" }, + }; + device.Connect(); + + var commands = await device.GetCommandHistoryAsync(); + + Assert.Contains("SYSTem:LOG:CMDHistory?", device.SentCommands); + Assert.Equal(new[] { "*IDN?", "SYSTem:LOG:TEST" }, commands); + } + + [Fact] + public async Task GetCommandHistoryAsync_WhenNoHistory_ReturnsEmpty() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "No command history" }, + }; + device.Connect(); + + Assert.Empty(await device.GetCommandHistoryAsync()); + } + + [Fact] + public async Task GetCommandHistoryAsync_WhenErrorOnlyResponse_Throws() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "**ERROR: -113,\"Undefined header\"" }, + }; + device.Connect(); + + await Assert.ThrowsAsync(() => device.GetCommandHistoryAsync()); + } + + [Fact] + public async Task TestSystemLogAsync_SendsCommand() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "Added test log messages" }, + }; + device.Connect(); + + await device.TestSystemLogAsync(); + + Assert.Contains("SYSTem:LOG:TEST", device.SentCommands); + } + + [Fact] + public async Task TestSystemLogAsync_WhenErrorOnlyResponse_Throws() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "**ERROR: -113,\"Undefined header\"" }, + }; + device.Connect(); + + await Assert.ThrowsAsync(() => device.TestSystemLogAsync()); + } + + [Fact] + public async Task GetSystemErrorCountAsync_SendsCommandAndParsesCount() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "3" }, + }; + device.Connect(); + + var count = await device.GetSystemErrorCountAsync(); + + Assert.Contains("SYSTem:ERRor:COUNt?", device.SentCommands); + Assert.Equal(3, count); + } + + [Fact] + public async Task GetSystemErrorCountAsync_WhenUnparseable_Throws() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "not a number" }, + }; + device.Connect(); + + await Assert.ThrowsAsync(() => device.GetSystemErrorCountAsync()); + } + + [Fact] + public async Task GetStreamStatsAsync_SendsCommandAndParsesStats() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "TotalSamplesStreamed=15000", "QueueDroppedSamples=0" }, + }; + device.Connect(); + + var stats = await device.GetStreamStatsAsync(); + + Assert.Contains("SYSTem:STReam:STATS?", device.SentCommands); + Assert.Equal(15000UL, stats.TotalSamplesStreamed); + } + + [Fact] + public async Task GetStreamStatsAsync_WhenUnparseable_Throws() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "**ERROR: -200,\"Execution error\"" }, + }; + device.Connect(); + + await Assert.ThrowsAsync(() => device.GetStreamStatsAsync()); + } + + [Fact] + public async Task GetMemoryDiagnosticsAsync_SendsCommandAndParsesValues() + { + var device = new TestableDiagnosticsDevice("TestDevice") + { + CannedTextResponse = { "HeapTotal=75000", "HeapFree=45000" }, + }; + device.Connect(); + + var mem = await device.GetMemoryDiagnosticsAsync(); + + Assert.Contains("SYSTem:MEMory:FREE?", device.SentCommands); + Assert.Equal(75000UL, mem.HeapTotal); + Assert.Equal(45000UL, mem.HeapFree); + } + + [Fact] + public async Task GetMemoryDiagnosticsAsync_WhenDisconnected_Throws() + { + var device = new TestableDiagnosticsDevice("TestDevice"); + + await Assert.ThrowsAsync(() => device.GetMemoryDiagnosticsAsync()); + } + + /// + /// A streaming device whose text-command exchange returns a canned response and records the + /// SCPI commands sent during the exchange, so diagnostics methods can be tested without a + /// real transport (mirrors the SD card test harness). + /// + private sealed class TestableDiagnosticsDevice : DaqifiStreamingDevice + { + public List SentCommands { get; } = new(); + public List CannedTextResponse { get; } = new(); + + public TestableDiagnosticsDevice(string name, IPAddress? ipAddress = null) + : base(name, ipAddress) + { + } + + public override void Send(IOutboundMessage message) + { + if (message is IOutboundMessage stringMessage) + { + SentCommands.Add(stringMessage.Data); + } + } + + protected override Task> ExecuteTextCommandAsync( + Action setupAction, + int responseTimeoutMs = 1000, + int completionTimeoutMs = 250, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + setupAction(); + return Task.FromResult>(CannedTextResponse.ToList()); + } + + protected override async Task> ExecuteTextCommandAsync( + Func setupActionAsync, + int responseTimeoutMs = 1000, + int completionTimeoutMs = 250, + CancellationToken cancellationToken = default) + { + await setupActionAsync(cancellationToken).ConfigureAwait(false); + return CannedTextResponse.ToList(); + } + } +} diff --git a/src/Daqifi.Core.Tests/Device/Diagnostics/LogLevelParserTests.cs b/src/Daqifi.Core.Tests/Device/Diagnostics/LogLevelParserTests.cs new file mode 100644 index 00000000..14192194 --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Diagnostics/LogLevelParserTests.cs @@ -0,0 +1,63 @@ +using Daqifi.Core.Device.Diagnostics; + +namespace Daqifi.Core.Tests.Device.Diagnostics; + +public class LogLevelParserTests +{ + [Fact] + public void TryParse_ParsesModuleLevelAndCeiling() + { + var ok = LogLevelParser.TryParse("STREAM: 2 (ceiling 3)", out var setting); + + Assert.True(ok); + Assert.Equal("STREAM", setting!.Module); + Assert.Equal(2, setting.Level); + Assert.Equal(3, setting.Ceiling); + } + + [Fact] + public void TryParse_TrimsAndIsCaseInsensitiveOnCeilingKeyword() + { + var ok = LogLevelParser.TryParse(" WIFI: 1 (CEILING 3)\r\n", out var setting); + + Assert.True(ok); + Assert.Equal("WIFI", setting!.Module); + Assert.Equal(1, setting.Level); + Assert.Equal(3, setting.Ceiling); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not a level line")] + [InlineData("STREAM: 2")] + [InlineData("**ERROR: -224,\"Illegal parameter value\"")] + public void TryParse_WhenUnparseable_ReturnsFalse(string line) + { + var ok = LogLevelParser.TryParse(line, out var setting); + + Assert.False(ok); + Assert.Null(setting); + } + + [Fact] + public void TryParseLines_ReturnsFirstParseableLine() + { + var lines = new[] { "garbage", "ADC: 0 (ceiling 3)", "DAC: 3 (ceiling 3)" }; + + var ok = LogLevelParser.TryParseLines(lines, out var setting); + + Assert.True(ok); + Assert.Equal("ADC", setting!.Module); + Assert.Equal(0, setting.Level); + } + + [Fact] + public void TryParseLines_WhenNoneParse_ReturnsFalse() + { + var ok = LogLevelParser.TryParseLines(new[] { "a", "b" }, out var setting); + + Assert.False(ok); + Assert.Null(setting); + } +} diff --git a/src/Daqifi.Core.Tests/Device/Diagnostics/MemoryDiagnosticsParserTests.cs b/src/Daqifi.Core.Tests/Device/Diagnostics/MemoryDiagnosticsParserTests.cs new file mode 100644 index 00000000..c49b612c --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Diagnostics/MemoryDiagnosticsParserTests.cs @@ -0,0 +1,62 @@ +using Daqifi.Core.Device.Diagnostics; + +namespace Daqifi.Core.Tests.Device.Diagnostics; + +public class MemoryDiagnosticsParserTests +{ + // Mirrors the firmware SCPI_GetMemFree output (HeapTotal/HeapFree/... = value). + private static readonly string[] SampleResponse = + { + "HeapTotal=75000", + "HeapFree=45000", + "HeapUsed=30000", + "HeapMinEverFree=13000", + "CoherentPoolTotal=32768", + "CoherentPoolFree=16384", + "SdCircularSize=8192", + "SamplePoolCount=1100", + "SampleElementBytes=32", + "SamplePoolInUse=4", + "SamplePoolMaxUsed=12", + }; + + [Fact] + public void TryParse_ParsesHeadlineFieldsAndRawValues() + { + var ok = MemoryDiagnosticsParser.TryParse(SampleResponse, out var mem); + + Assert.True(ok); + Assert.NotNull(mem); + Assert.Equal(75000UL, mem!.HeapTotal); + Assert.Equal(45000UL, mem.HeapFree); + Assert.Equal(30000UL, mem.HeapUsed); + Assert.Equal(13000UL, mem.HeapMinEverFree); + Assert.Equal(32768UL, mem.CoherentPoolTotal); + Assert.Equal(16384UL, mem.CoherentPoolFree); + Assert.Equal(1100UL, mem.SamplePoolCount); + Assert.Equal(4UL, mem.SamplePoolInUse); + Assert.Equal(12UL, mem.SamplePoolMaxUsed); + Assert.Equal(8192UL, mem.Values["SdCircularSize"]); + Assert.Equal(11, mem.Values.Count); + } + + [Fact] + public void TryParse_MissingFieldsReturnNull() + { + var ok = MemoryDiagnosticsParser.TryParse(new[] { "HeapFree=100" }, out var mem); + + Assert.True(ok); + Assert.Equal(100UL, mem!.HeapFree); + Assert.Null(mem.HeapTotal); + Assert.Null(mem.SamplePoolCount); + } + + [Fact] + public void TryParse_WhenNoParseablePairs_ReturnsFalse() + { + var ok = MemoryDiagnosticsParser.TryParse(new[] { "garbage", "" }, out var mem); + + Assert.False(ok); + Assert.Null(mem); + } +} diff --git a/src/Daqifi.Core.Tests/Device/Diagnostics/StreamStatsParserTests.cs b/src/Daqifi.Core.Tests/Device/Diagnostics/StreamStatsParserTests.cs new file mode 100644 index 00000000..1a5fca06 --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Diagnostics/StreamStatsParserTests.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using Daqifi.Core.Device.Diagnostics; + +namespace Daqifi.Core.Tests.Device.Diagnostics; + +public class StreamStatsParserTests +{ + // Representative subset of a real SYSTem:STReam:STATS? response (the device + // emits 40+ Key=Value lines; the parser keeps every numeric pair). + private static readonly string[] SampleResponse = + { + "TotalSamplesStreamed=15000", + "TotalBytesStreamed=480000", + "QueueDroppedSamples=0", + "PoolExhaustedSamples=0", + "UsbDroppedBytes=0", + "WifiTcpBytesSent=480000", + "TimerISRCalls=15000", + "SamplePoolMaxUsed=12", + }; + + [Fact] + public void TryParse_ParsesHeadlineCountersAndRawValues() + { + var ok = StreamStatsParser.TryParse(SampleResponse, out var stats); + + Assert.True(ok); + Assert.NotNull(stats); + Assert.Equal(15000UL, stats!.TotalSamplesStreamed); + Assert.Equal(480000UL, stats.TotalBytesStreamed); + Assert.Equal(0UL, stats.QueueDroppedSamples); + Assert.Equal(15000UL, stats.TimerISRCalls); + // Fields without a typed accessor are still available via Values. + Assert.Equal(480000UL, stats.Values["WifiTcpBytesSent"]); + Assert.Equal(8, stats.Values.Count); + } + + [Fact] + public void TryParse_HandlesLargeUInt64Values() + { + // 64-bit counters (%llu in firmware) must not overflow. + var lines = new[] { "TotalBytesStreamed=18446744073709551615" }; + + var ok = StreamStatsParser.TryParse(lines, out var stats); + + Assert.True(ok); + Assert.Equal(ulong.MaxValue, stats!.TotalBytesStreamed); + } + + [Fact] + public void TryParse_TrimsTrailingCarriageReturnsAndSkipsBlankLines() + { + var lines = new[] { "HeapNoise=1\r", "", " ", "TotalSamplesStreamed=42\r" }; + + var ok = StreamStatsParser.TryParse(lines, out var stats); + + Assert.True(ok); + Assert.Equal(42UL, stats!.TotalSamplesStreamed); + Assert.Equal(1UL, stats.Values["HeapNoise"]); + } + + [Fact] + public void TryParse_SkipsNonNumericAndErrorLines() + { + var lines = new[] + { + "**ERROR: -200,\"Execution error\"", + "BuildInfo=notanumber", + "TotalSamplesStreamed=7", + }; + + var ok = StreamStatsParser.TryParse(lines, out var stats); + + Assert.True(ok); + Assert.Single(stats!.Values); + Assert.Equal(7UL, stats.TotalSamplesStreamed); + } + + [Fact] + public void TryParse_MissingHeadlineFieldsReturnNull() + { + var ok = StreamStatsParser.TryParse(new[] { "SomeOtherField=3" }, out var stats); + + Assert.True(ok); + Assert.Null(stats!.TotalSamplesStreamed); + Assert.Null(stats.QueueDroppedSamples); + } + + [Fact] + public void TryParse_WhenNoParseablePairs_ReturnsFalse() + { + var ok = StreamStatsParser.TryParse(new[] { "**ERROR: -200,\"Execution error\"", "" }, out var stats); + + Assert.False(ok); + Assert.Null(stats); + } + + [Fact] + public void TryParse_WhenEmpty_ReturnsFalse() + { + var ok = StreamStatsParser.TryParse(new List(), out var stats); + + Assert.False(ok); + Assert.Null(stats); + } +} diff --git a/src/Daqifi.Core.Tests/Device/Diagnostics/SystemLogParserTests.cs b/src/Daqifi.Core.Tests/Device/Diagnostics/SystemLogParserTests.cs new file mode 100644 index 00000000..5ab77b17 --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/Diagnostics/SystemLogParserTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Linq; +using Daqifi.Core.Device.Diagnostics; + +namespace Daqifi.Core.Tests.Device.Diagnostics; + +public class SystemLogParserTests +{ + [Fact] + public void Parse_ReturnsOneEntryPerNonEmptyLine_InOrder() + { + // Matches the messages SYSTem:LOG:TEST injects. + var lines = new[] + { + "Test log message 1", + "Test error message", + "Test info message", + "Test message 0", + }; + + var entries = SystemLogParser.Parse(lines); + + Assert.Equal(4, entries.Count); + Assert.Equal("Test log message 1", entries[0].Message); + Assert.Equal("Test error message", entries[1].Message); + Assert.Equal("Test message 0", entries[3].Message); + } + + [Fact] + public void Parse_SkipsBlankLinesAndTrimsLineEndings() + { + var lines = new[] { "first\r", "", " ", "second\r" }; + + var entries = SystemLogParser.Parse(lines); + + Assert.Equal(new[] { "first", "second" }, entries.Select(e => e.Message)); + } + + [Fact] + public void Parse_DropsScpiErrorAndStatusLines() + { + var lines = new[] + { + "**ERROR: -113,\"Undefined header\"", + "Error!! something bad", + "Real log line", + }; + + var entries = SystemLogParser.Parse(lines); + + Assert.Single(entries); + Assert.Equal("Real log line", entries[0].Message); + } + + [Fact] + public void Parse_KeepsLogContentThatMerelyMentionsError() + { + // "error" inside the message must not trigger the error-line filter + // (only true SCPI error / firmware status prefixes are dropped). + var lines = new[] { "Test error message", "ADC saturation error detected" }; + + var entries = SystemLogParser.Parse(lines); + + Assert.Equal(2, entries.Count); + } + + [Fact] + public void Parse_WhenEmpty_ReturnsEmpty() + { + Assert.Empty(SystemLogParser.Parse(Array.Empty())); + } + + [Fact] + public void Parse_WhenNull_Throws() + { + Assert.Throws(() => SystemLogParser.Parse(null!)); + } +} diff --git a/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs b/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs index a7ef2a57..3e7c2588 100644 --- a/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs +++ b/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs @@ -703,4 +703,132 @@ public static IOutboundMessage SetUsbTransparencyMode(int mode) /// Example: messageProducer.Send(ScpiMessageProducer.GetLanChipInfo); /// public static IOutboundMessage GetLanChipInfo => new ScpiMessage("SYSTem:COMMunicate:LAN:GETChipInfo?"); + + // --------------------------------------------------------------------- + // Logging & diagnostics + // + // Firmware exposes a system log buffer, runtime log levels, SCPI command + // history, the SCPI error-queue depth, and streaming/memory performance + // counters. See the firmware wiki "SCPI Interface — Logging & Diagnostics" + // and Device.Diagnostics.IDeviceDiagnostics for the typed wrappers. + // --------------------------------------------------------------------- + + /// + /// Creates a query message to read and clear the device's system log buffer. + /// + /// + /// Returns all buffered log entries as free-form text lines (one per entry) and clears the + /// buffer as a side effect (it also resets one-shot log suppression flags). The firmware does + /// not prefix entries with a structured level/module/timestamp; each line is the raw message. + /// Command: SYSTem:LOG? + /// Example: messageProducer.Send(ScpiMessageProducer.GetSystemLog); + /// + public static IOutboundMessage GetSystemLog => new ScpiMessage("SYSTem:LOG?"); + + /// + /// Creates a command message to clear the device's system log buffer without reading it. + /// + /// + /// Also resets one-shot log suppression flags. The device replies with a short + /// acknowledgement (Log cleared). + /// Command: SYSTem:LOG:CLEar + /// Example: messageProducer.Send(ScpiMessageProducer.ClearSystemLog); + /// + public static IOutboundMessage ClearSystemLog => new ScpiMessage("SYSTem:LOG:CLEar"); + + /// + /// Creates a command message to set the runtime log level for a single module. + /// + /// The module name (e.g. STREAM, WIFI, SD, USB, SCPI, + /// ADC, DAC, POWER, ENCODER, GENERAL). Case-insensitive on the device. + /// The log level: 0 = None, 1 = Error, 2 = Info, 3 = Debug. + /// + /// The device echoes the applied level and the compile-time ceiling + /// (MODULE: <level> (ceiling <ceiling>)); the applied level may be lower than requested + /// when a module's ceiling is below it. + /// Command: SYSTem:LOG:LEVel module,level + /// Example: messageProducer.Send(ScpiMessageProducer.SetLogLevel("STREAM", 2)); + /// + public static IOutboundMessage SetLogLevel(string module, int level) + { + if (string.IsNullOrWhiteSpace(module)) + { + throw new ArgumentException("Module name cannot be null or empty.", nameof(module)); + } + + // Reject characters that would break out of the parameter or inject a + // second SCPI command/parameter. Module names are short alpha tokens, + // so any of these indicates malformed input. + if (module.IndexOfAny(new[] { '"', '\n', '\r', ';', ',', ' ', '\t' }) >= 0) + { + throw new ArgumentException( + "Module name contains invalid characters. Quotes, commas, semicolons, and whitespace are not allowed.", + nameof(module)); + } + + if (level < 0 || level > 3) + { + throw new ArgumentOutOfRangeException(nameof(level), level, "Log level must be between 0 (None) and 3 (Debug)."); + } + + return new ScpiMessage($"SYSTem:LOG:LEVel {module},{level}"); + } + + /// + /// Creates a query message to read the device's recent SCPI command history. + /// + /// + /// Returns the most recent commands seen on the USB interface, newest first, prefixed with a + /// Last N commands: header (or No command history when empty). + /// Command: SYSTem:LOG:CMDHistory? + /// Example: messageProducer.Send(ScpiMessageProducer.GetCommandHistory); + /// + public static IOutboundMessage GetCommandHistory => new ScpiMessage("SYSTem:LOG:CMDHistory?"); + + /// + /// Creates a command message that injects a handful of test messages into the system log. + /// + /// + /// Intended for verifying the logging pipeline end to end; the device replies with a short + /// acknowledgement (Added test log messages). + /// Command: SYSTem:LOG:TEST + /// Example: messageProducer.Send(ScpiMessageProducer.TestSystemLog); + /// + public static IOutboundMessage TestSystemLog => new ScpiMessage("SYSTem:LOG:TEST"); + + /// + /// Creates a query message to read the number of entries currently in the SCPI error queue. + /// + /// + /// Non-destructive: unlike , this does not pop any entries. + /// Returns a single integer. + /// Command: SYSTem:ERRor:COUNt? + /// Example: messageProducer.Send(ScpiMessageProducer.GetSystemErrorCount); + /// + public static IOutboundMessage GetSystemErrorCount => new ScpiMessage("SYSTem:ERRor:COUNt?"); + + /// + /// Creates a query message to read streaming performance counters. + /// + /// + /// Returns a set of Key=Value lines describing the current/last streaming session + /// (e.g. TotalSamplesStreamed, TotalBytesStreamed, QueueDroppedSamples, + /// per-transport dropped-byte counters, SD write metrics, and TimerISRCalls). The exact + /// field set varies by firmware version. + /// Command: SYSTem:STReam:STATS? + /// Example: messageProducer.Send(ScpiMessageProducer.GetStreamStats); + /// + public static IOutboundMessage GetStreamStats => new ScpiMessage("SYSTem:STReam:STATS?"); + + /// + /// Creates a query message to read device memory diagnostics. + /// + /// + /// Returns a set of Key=Value lines describing heap and pool usage (e.g. HeapTotal, + /// HeapFree, HeapUsed, HeapMinEverFree, CoherentPoolTotal, + /// CoherentPoolFree, and sample-pool counters). The exact field set varies by firmware version. + /// Command: SYSTem:MEMory:FREE? + /// Example: messageProducer.Send(ScpiMessageProducer.GetMemoryDiagnostics); + /// + public static IOutboundMessage GetMemoryDiagnostics => new ScpiMessage("SYSTem:MEMory:FREE?"); } \ No newline at end of file diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 32343876..41810844 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -3,6 +3,7 @@ using Daqifi.Core.Communication.Messages; using Daqifi.Core.Communication.Producers; using Daqifi.Core.Communication.Transport; +using Daqifi.Core.Device.Diagnostics; using Daqifi.Core.Device.Network; using Daqifi.Core.Device.SdCard; using Daqifi.Core.Firmware; @@ -24,7 +25,7 @@ namespace Daqifi.Core.Device /// Represents a DAQiFi device that supports data streaming functionality. /// Extends the base DaqifiDevice with streaming-specific operations. /// - public class DaqifiStreamingDevice : DaqifiDevice, IStreamingDevice, INetworkConfigurable, ISdCardOperations, ILanChipInfoProvider + public class DaqifiStreamingDevice : DaqifiDevice, IStreamingDevice, INetworkConfigurable, ISdCardOperations, ILanChipInfoProvider, IDeviceDiagnostics { /// /// The delay in milliseconds to wait for the WiFi module to restart after applying configuration. @@ -1316,5 +1317,273 @@ private static void ValidateSdCardFileName(string fileName) LanChipInfoParser.TryParseLines(lines, out var info); return info; } + + // ----------------------------------------------------------------- + // IDeviceDiagnostics + // + // Each method issues a single SCPI query/command as a text command + // (the protobuf consumer is paused for the exchange, same as the SD + // and LAN-chip queries) and hands the response to a tolerant parser. + // Unlike the SD operations these do not switch the SPI bus, so there + // is no PrepareSdInterface / settle delay; and they intentionally do + // not stop streaming, so callers can sample live counters — though + // parsing is most reliable when the device is not actively streaming. + // ----------------------------------------------------------------- + + /// Time allowed for the first diagnostics response line. Generous because + /// SYSTem:LOG? and the stats queries can emit dozens of lines. + private const int DIAGNOSTICS_RESPONSE_TIMEOUT_MS = 2000; + + /// + /// Throws a when a diagnostics command produced no + /// usable result and the device's response consisted solely of SCPI error/status lines — + /// i.e. the command failed (commonly an unsupported header on below-floor firmware) rather + /// than legitimately returning nothing. A truly empty response (no lines) is treated as + /// success so callers can distinguish "empty log" from "command failed". + /// + private static void ThrowIfErrorOnlyResponse(int parsedResultCount, IReadOnlyList lines, string operation) + { + if (parsedResultCount == 0 && IsErrorOnlyResponse(lines)) + { + throw new DeviceDiagnosticsException( + $"The device returned an error while attempting to {operation}.", + lines); + } + } + + /// + /// Returns true when the response contains at least one non-empty line and every non-empty + /// line is a SCPI error/status line (per ). + /// + private static bool IsErrorOnlyResponse(IReadOnlyList lines) + { + var sawContent = false; + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + sawContent = true; + if (!IsNonResultLine(line)) + { + return false; + } + } + + return sawContent; + } + + /// + public async Task> GetSystemLogAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected) + { + throw new InvalidOperationException("Device is not connected."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var lines = await ExecuteTextCommandAsync( + () => Send(ScpiMessageProducer.GetSystemLog), + responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS, + cancellationToken: cancellationToken).ConfigureAwait(false); + + var entries = SystemLogParser.Parse(lines); + + // The parser drops error/status lines, so an error-only response would + // otherwise be indistinguishable from a genuinely empty log buffer. + // Surface a command failure (e.g. unsupported on below-floor firmware) + // rather than returning a misleading empty list. + ThrowIfErrorOnlyResponse(entries.Count, lines, "read the system log"); + + return entries; + } + + /// + public async Task ClearSystemLogAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected) + { + throw new InvalidOperationException("Device is not connected."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var lines = await ExecuteTextCommandAsync( + () => Send(ScpiMessageProducer.ClearSystemLog), + responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS, + cancellationToken: cancellationToken).ConfigureAwait(false); + + // On success the device echoes a short ack ("Log cleared"); an error-only + // response means the command failed and must not be swallowed. + ThrowIfErrorOnlyResponse(0, lines, "clear the system log"); + } + + /// + public async Task SetLogLevelAsync(string module, int level, CancellationToken cancellationToken = default) + { + // Build the command first so argument validation (ArgumentException / + // ArgumentOutOfRangeException) surfaces the same way regardless of + // connection state, matching SetAnalogOutput / SetDioDirection. + var command = ScpiMessageProducer.SetLogLevel(module, level); + + if (!IsConnected) + { + throw new InvalidOperationException("Device is not connected."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var lines = await ExecuteTextCommandAsync( + () => Send(command), + responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (ContainsScpiError(lines)) + { + throw new DeviceDiagnosticsException( + $"The device rejected log level {level} for module '{module}'.", + lines); + } + + if (LogLevelParser.TryParseLines(lines, out var setting)) + { + return setting; + } + + throw new DeviceDiagnosticsException( + $"Setting the log level for module '{module}' returned an unparseable response.", + lines); + } + + /// + public async Task> GetCommandHistoryAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected) + { + throw new InvalidOperationException("Device is not connected."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var lines = await ExecuteTextCommandAsync( + () => Send(ScpiMessageProducer.GetCommandHistory), + responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS, + cancellationToken: cancellationToken).ConfigureAwait(false); + + var commands = CommandHistoryParser.Parse(lines); + + // An empty list is valid ("No command history"), but an error-only + // response is a failure — distinguish the two. The "No command history" + // marker is not an error line, so it never trips this check. + ThrowIfErrorOnlyResponse(commands.Count, lines, "read the command history"); + + return commands; + } + + /// + public async Task TestSystemLogAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected) + { + throw new InvalidOperationException("Device is not connected."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var lines = await ExecuteTextCommandAsync( + () => Send(ScpiMessageProducer.TestSystemLog), + responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS, + cancellationToken: cancellationToken).ConfigureAwait(false); + + // On success the device echoes "Added test log messages"; an error-only + // response means the command failed and must not be swallowed. + ThrowIfErrorOnlyResponse(0, lines, "run the system-log self-test"); + } + + /// + public async Task GetSystemErrorCountAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected) + { + throw new InvalidOperationException("Device is not connected."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var lines = await ExecuteTextCommandAsync( + () => Send(ScpiMessageProducer.GetSystemErrorCount), + responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS, + cancellationToken: cancellationToken).ConfigureAwait(false); + + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + if (int.TryParse(line.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var count)) + { + return count; + } + } + + throw new DeviceDiagnosticsException( + "The error-count query returned an unparseable response.", + lines); + } + + /// + public async Task GetStreamStatsAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected) + { + throw new InvalidOperationException("Device is not connected."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var lines = await ExecuteTextCommandAsync( + () => Send(ScpiMessageProducer.GetStreamStats), + responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (StreamStatsParser.TryParse(lines, out var stats)) + { + return stats; + } + + throw new DeviceDiagnosticsException( + "The streaming-stats query returned an unparseable response.", + lines); + } + + /// + public async Task GetMemoryDiagnosticsAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected) + { + throw new InvalidOperationException("Device is not connected."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var lines = await ExecuteTextCommandAsync( + () => Send(ScpiMessageProducer.GetMemoryDiagnostics), + responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (MemoryDiagnosticsParser.TryParse(lines, out var diagnostics)) + { + return diagnostics; + } + + throw new DeviceDiagnosticsException( + "The memory-diagnostics query returned an unparseable response.", + lines); + } } } diff --git a/src/Daqifi.Core/Device/Diagnostics/CommandHistoryParser.cs b/src/Daqifi.Core/Device/Diagnostics/CommandHistoryParser.cs new file mode 100644 index 00000000..558e1a58 --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/CommandHistoryParser.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Daqifi.Core.Device.Diagnostics; + +/// +/// Parses the response from the SYSTem:LOG:CMDHistory? SCPI query into a list of commands. +/// +/// +/// The firmware returns a Last N commands: header followed by one <n>: <command> +/// line per remembered command (newest first), or the single line No command history when the +/// buffer is empty. This parser strips the header and the numeric prefix, returning just the command +/// text in the order the device reported it. +/// +public static class CommandHistoryParser +{ + private const string NoHistoryMarker = "No command history"; + + /// + /// Parses command-history response lines into command strings. + /// + /// The raw response lines from the device. + /// The remembered commands (newest first); empty when there is no history. + /// Thrown when is null. + public static IReadOnlyList Parse(IEnumerable lines) + { + if (lines == null) + { + throw new ArgumentNullException(nameof(lines)); + } + + var commands = new List(); + + foreach (var rawLine in lines) + { + if (string.IsNullOrWhiteSpace(rawLine)) + { + continue; + } + + var line = rawLine.Trim(); + + if (line.Equals(NoHistoryMarker, StringComparison.OrdinalIgnoreCase)) + { + return Array.Empty(); + } + + // Each command line is ": ". Lines that don't match + // (e.g. the "Last N commands:" header) are skipped: the header's + // numeric portion is absent, so the index parse fails. + var colonIndex = line.IndexOf(':'); + if (colonIndex <= 0) + { + continue; + } + + var indexSpan = line.AsSpan(0, colonIndex).Trim(); + if (!int.TryParse(indexSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out _)) + { + continue; + } + + var command = line.Substring(colonIndex + 1).Trim(); + if (command.Length > 0) + { + commands.Add(command); + } + } + + return commands; + } +} diff --git a/src/Daqifi.Core/Device/Diagnostics/DeviceDiagnosticsException.cs b/src/Daqifi.Core/Device/Diagnostics/DeviceDiagnosticsException.cs new file mode 100644 index 00000000..db3601f3 --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/DeviceDiagnosticsException.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; + +#nullable enable + +namespace Daqifi.Core.Device.Diagnostics +{ + /// + /// Represents a failure while performing a device diagnostics operation — for example, the + /// device returned a SCPI error or a response that could not be parsed into the expected + /// structured type. Carries the raw response lines so callers can surface or log the underlying + /// device output without re-parsing the wire data. + /// + public class DeviceDiagnosticsException : Exception + { + /// + /// Gets the raw response lines captured from the device when the failure was detected. + /// + public IReadOnlyList RawDeviceResponse { get; } + + /// + /// Initializes a new instance of the class. + /// + public DeviceDiagnosticsException( + string message, + IReadOnlyList rawDeviceResponse, + Exception? innerException = null) + : base(message, innerException) + { + RawDeviceResponse = rawDeviceResponse ?? Array.Empty(); + } + } +} diff --git a/src/Daqifi.Core/Device/Diagnostics/IDeviceDiagnostics.cs b/src/Daqifi.Core/Device/Diagnostics/IDeviceDiagnostics.cs new file mode 100644 index 00000000..faa3c851 --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/IDeviceDiagnostics.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable + +namespace Daqifi.Core.Device.Diagnostics +{ + /// + /// Exposes the firmware's logging and diagnostics surface — the system log buffer, runtime log + /// levels, SCPI command history, error-queue depth, and streaming/memory performance counters. + /// + /// + /// These values originate on the device; this interface is a typed wrapper over the corresponding + /// SCPI queries, not a client-side instrumentation framework. All methods require an established + /// connection and run as text commands, so avoid issuing them concurrently with each other or with + /// other text-mode operations on the same device. For reliable parsing, prefer querying while the + /// device is not actively streaming. + /// + public interface IDeviceDiagnostics + { + /// + /// Reads and clears the device system log buffer (SYSTem:LOG?). + /// + /// A cancellation token to observe while waiting for the task to complete. + /// The buffered log entries, oldest first; empty when the buffer was empty. + /// Thrown when the device is not connected. + Task> GetSystemLogAsync(CancellationToken cancellationToken = default); + + /// + /// Clears the device system log buffer without reading it (SYSTem:LOG:CLEar). + /// + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + /// Thrown when the device is not connected. + Task ClearSystemLogAsync(CancellationToken cancellationToken = default); + + /// + /// Sets the runtime log level for a single firmware module (SYSTem:LOG:LEVel). + /// + /// The module name (e.g. STREAM, WIFI, SD). Case-insensitive on the device. + /// The log level: 0 = None, 1 = Error, 2 = Info, 3 = Debug. + /// A cancellation token to observe while waiting for the task to complete. + /// The level actually applied, as echoed by the device (may be capped by the module's ceiling). + /// Thrown when the device is not connected. + /// Thrown when is null, empty, or contains invalid characters. + /// Thrown when is outside 0–3. + /// Thrown when the device rejected the request or returned an unparseable response. + Task SetLogLevelAsync(string module, int level, CancellationToken cancellationToken = default); + + /// + /// Reads the device's recent SCPI command history (SYSTem:LOG:CMDHistory?). + /// + /// A cancellation token to observe while waiting for the task to complete. + /// The remembered commands, newest first; empty when there is no history. + /// Thrown when the device is not connected. + Task> GetCommandHistoryAsync(CancellationToken cancellationToken = default); + + /// + /// Injects a handful of test messages into the system log for pipeline verification (SYSTem:LOG:TEST). + /// + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + /// Thrown when the device is not connected. + Task TestSystemLogAsync(CancellationToken cancellationToken = default); + + /// + /// Reads the number of entries currently in the SCPI error queue without popping them + /// (SYSTem:ERRor:COUNt?). + /// + /// A cancellation token to observe while waiting for the task to complete. + /// The current error-queue depth. + /// Thrown when the device is not connected. + /// Thrown when the device returned an unparseable response. + Task GetSystemErrorCountAsync(CancellationToken cancellationToken = default); + + /// + /// Reads streaming performance counters (SYSTem:STReam:STATS?). + /// + /// A cancellation token to observe while waiting for the task to complete. + /// The parsed streaming statistics. + /// Thrown when the device is not connected. + /// Thrown when the device returned an unparseable response. + Task GetStreamStatsAsync(CancellationToken cancellationToken = default); + + /// + /// Reads device memory diagnostics (SYSTem:MEMory:FREE?). + /// + /// A cancellation token to observe while waiting for the task to complete. + /// The parsed memory diagnostics. + /// Thrown when the device is not connected. + /// Thrown when the device returned an unparseable response. + Task GetMemoryDiagnosticsAsync(CancellationToken cancellationToken = default); + } +} diff --git a/src/Daqifi.Core/Device/Diagnostics/KeyValueResponseParser.cs b/src/Daqifi.Core/Device/Diagnostics/KeyValueResponseParser.cs new file mode 100644 index 00000000..867dfb5a --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/KeyValueResponseParser.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Globalization; + +namespace Daqifi.Core.Device.Diagnostics; + +/// +/// Parses the Key=Value line-oriented responses shared by the streaming-stats and +/// memory-diagnostics SCPI queries into a dictionary of unsigned integer counters. +/// +/// +/// The parser is deliberately tolerant: it skips blank lines, SCPI error/status lines, lines +/// without a = separator, and pairs whose value is not a non-negative integer. This keeps a +/// firmware revision that adds new (or non-numeric) fields from breaking the parse — known numeric +/// fields are still extracted. Duplicate keys keep the last value seen. +/// +internal static class KeyValueResponseParser +{ + /// + /// Parses response lines into a case-sensitive map of field name to unsigned value. + /// + /// The raw response lines from the device. + /// The parsed key/value pairs; empty when no parseable pair was found. + public static IReadOnlyDictionary Parse(IEnumerable lines) + { + var result = new Dictionary(); + + if (lines == null) + { + return result; + } + + foreach (var rawLine in lines) + { + if (string.IsNullOrWhiteSpace(rawLine)) + { + continue; + } + + var line = rawLine.Trim(); + + // Drop SCPI error responses and firmware status text so a stray + // error interleaved with the counters doesn't pollute the result. + if (ScpiResponseClassifier.IsErrorResponseLine(line)) + { + continue; + } + + var separatorIndex = line.IndexOf('='); + if (separatorIndex <= 0 || separatorIndex == line.Length - 1) + { + continue; + } + + var key = line.Substring(0, separatorIndex).Trim(); + var valueSpan = line.AsSpan(separatorIndex + 1).Trim(); + + if (key.Length == 0) + { + continue; + } + + if (!ulong.TryParse(valueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) + { + continue; + } + + result[key] = value; + } + + return result; + } +} diff --git a/src/Daqifi.Core/Device/Diagnostics/LogLevelParser.cs b/src/Daqifi.Core/Device/Diagnostics/LogLevelParser.cs new file mode 100644 index 00000000..e872384f --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/LogLevelParser.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; + +namespace Daqifi.Core.Device.Diagnostics; + +/// +/// Parses the MODULE: <level> (ceiling <ceiling>) echo returned by the +/// SYSTem:LOG:LEVel module,level command into a . +/// +public static partial class LogLevelParser +{ + // e.g. "STREAM: 2 (ceiling 3)". Tolerant of surrounding/internal whitespace. + private static readonly Regex LevelRegex = BuildLevelRegex(); + + /// + /// Attempts to parse a single echo line into a . + /// + /// A response line, e.g. "STREAM: 2 (ceiling 3)". + /// The parsed setting, or if parsing failed. + /// if parsing succeeded; otherwise . + public static bool TryParse(string? line, [NotNullWhen(true)] out LogLevelSetting? result) + { + result = null; + + if (string.IsNullOrWhiteSpace(line)) + { + return false; + } + + var match = LevelRegex.Match(line.Trim()); + if (!match.Success) + { + return false; + } + + // The numeric groups are bounded by \d+ in the pattern; int.Parse is safe + // for the realistic single-digit levels the firmware emits. + result = new LogLevelSetting + { + Module = match.Groups["module"].Value, + Level = int.Parse(match.Groups["level"].Value), + Ceiling = int.Parse(match.Groups["ceiling"].Value), + }; + return true; + } + + /// + /// Attempts to parse a from a sequence of response lines, + /// trying each non-empty line in order until one succeeds. + /// + /// Response lines from the device. + /// The parsed setting, or if no line could be parsed. + /// if any line was successfully parsed; otherwise . + public static bool TryParseLines(IEnumerable lines, [NotNullWhen(true)] out LogLevelSetting? result) + { + foreach (var line in lines) + { + if (TryParse(line, out result)) + { + return true; + } + } + + result = null; + return false; + } + + [GeneratedRegex(@"^(?\S+):\s*(?\d+)\s*\(ceiling\s*(?\d+)\)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex BuildLevelRegex(); +} diff --git a/src/Daqifi.Core/Device/Diagnostics/LogLevelSetting.cs b/src/Daqifi.Core/Device/Diagnostics/LogLevelSetting.cs new file mode 100644 index 00000000..aeff8d6f --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/LogLevelSetting.cs @@ -0,0 +1,29 @@ +namespace Daqifi.Core.Device.Diagnostics; + +/// +/// The result of applying a runtime log level, echoed by the device from a +/// SYSTem:LOG:LEVel module,level command. +/// +/// +/// Log levels are: 0 = None, 1 = Error, 2 = Info, 3 = Debug. The actually +/// applied can be lower than the requested value when the module's compile-time +/// caps it. +/// +public sealed record LogLevelSetting +{ + /// + /// Gets the module name the level was applied to (e.g. STREAM), as reported by the device. + /// + public required string Module { get; init; } + + /// + /// Gets the log level now in effect for the module (0 = None, 1 = Error, 2 = Info, 3 = Debug). + /// + public required int Level { get; init; } + + /// + /// Gets the compile-time ceiling for the module. The effective can never + /// exceed this value. + /// + public required int Ceiling { get; init; } +} diff --git a/src/Daqifi.Core/Device/Diagnostics/MemoryDiagnostics.cs b/src/Daqifi.Core/Device/Diagnostics/MemoryDiagnostics.cs new file mode 100644 index 00000000..9633d11a --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/MemoryDiagnostics.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; + +namespace Daqifi.Core.Device.Diagnostics; + +/// +/// Device memory diagnostics returned by the SYSTem:MEMory:FREE? SCPI query. +/// +/// +/// The firmware emits a set of Key=Value lines describing FreeRTOS heap usage and the +/// coherent/sample memory pools. The exact field set varies by firmware version; all parsed pairs +/// are available via , and the typed properties are convenience accessors that +/// return when the field is absent from the response. +/// +public sealed record MemoryDiagnostics +{ + /// + /// Gets all parsed Key=Value fields from the response, keyed by field name. + /// + public required IReadOnlyDictionary Values { get; init; } + + /// + /// Gets the total FreeRTOS heap size in bytes, or if absent. + /// + public ulong? HeapTotal => GetValue("HeapTotal"); + + /// + /// Gets the currently free heap in bytes, or if absent. + /// + public ulong? HeapFree => GetValue("HeapFree"); + + /// + /// Gets the currently used heap in bytes, or if absent. + /// + public ulong? HeapUsed => GetValue("HeapUsed"); + + /// + /// Gets the lowest free-heap watermark observed since boot, in bytes, or if absent. + /// + public ulong? HeapMinEverFree => GetValue("HeapMinEverFree"); + + /// + /// Gets the total size of the coherent DMA pool in bytes, or if absent. + /// + public ulong? CoherentPoolTotal => GetValue("CoherentPoolTotal"); + + /// + /// Gets the free space in the coherent DMA pool in bytes, or if absent. + /// + public ulong? CoherentPoolFree => GetValue("CoherentPoolFree"); + + /// + /// Gets the capacity of the analog-input sample pool (number of elements), or if absent. + /// + public ulong? SamplePoolCount => GetValue("SamplePoolCount"); + + /// + /// Gets the number of sample-pool elements currently in use, or if absent. + /// + public ulong? SamplePoolInUse => GetValue("SamplePoolInUse"); + + /// + /// Gets the peak number of sample-pool elements used since boot, or if absent. + /// + public ulong? SamplePoolMaxUsed => GetValue("SamplePoolMaxUsed"); + + private ulong? GetValue(string key) => + Values.TryGetValue(key, out var value) ? value : null; +} diff --git a/src/Daqifi.Core/Device/Diagnostics/MemoryDiagnosticsParser.cs b/src/Daqifi.Core/Device/Diagnostics/MemoryDiagnosticsParser.cs new file mode 100644 index 00000000..03e8cbee --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/MemoryDiagnosticsParser.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Daqifi.Core.Device.Diagnostics; + +/// +/// Parses the response from the SYSTem:MEMory:FREE? SCPI query into a . +/// +/// +/// The firmware returns a set of Key=Value lines (see ). +/// Parsing succeeds when at least one field is recognized. +/// +public static class MemoryDiagnosticsParser +{ + /// + /// Attempts to parse memory diagnostics from a sequence of response lines. + /// + /// Response lines from the device. + /// The parsed diagnostics, or if no field could be parsed. + /// if at least one field was parsed; otherwise . + public static bool TryParse(IEnumerable lines, [NotNullWhen(true)] out MemoryDiagnostics? result) + { + var values = KeyValueResponseParser.Parse(lines); + if (values.Count == 0) + { + result = null; + return false; + } + + result = new MemoryDiagnostics { Values = values }; + return true; + } +} diff --git a/src/Daqifi.Core/Device/Diagnostics/StreamStats.cs b/src/Daqifi.Core/Device/Diagnostics/StreamStats.cs new file mode 100644 index 00000000..d8ec31f0 --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/StreamStats.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; + +namespace Daqifi.Core.Device.Diagnostics; + +/// +/// Streaming performance counters returned by the SYSTem:STReam:STATS? SCPI query. +/// +/// +/// The firmware emits a set of Key=Value lines whose exact membership varies by firmware +/// version (40+ fields covering queue/pool drops, per-transport dropped bytes, SD write metrics, +/// encoder failures, and timer ISR accounting). All parsed pairs are available via +/// ; the typed properties are convenience accessors for the most commonly used +/// headline counters and return when the field is absent from the response. +/// +public sealed record StreamStats +{ + /// + /// Gets all parsed Key=Value counters from the response, keyed by field name. + /// + public required IReadOnlyDictionary Values { get; init; } + + /// + /// Gets the total number of samples successfully queued from the acquisition ISR this session, + /// or if absent. + /// + public ulong? TotalSamplesStreamed => GetValue("TotalSamplesStreamed"); + + /// + /// Gets the total number of bytes encoded/offered to the output transport this session, + /// or if absent. + /// + public ulong? TotalBytesStreamed => GetValue("TotalBytesStreamed"); + + /// + /// Gets the number of samples dropped because the streaming queue or sample pool could not keep + /// up, or if absent. + /// + public ulong? QueueDroppedSamples => GetValue("QueueDroppedSamples"); + + /// + /// Gets the number of streaming timer ISR entries this session, or if absent. + /// In a healthy session this equals + . + /// + public ulong? TimerISRCalls => GetValue("TimerISRCalls"); + + private ulong? GetValue(string key) => + Values.TryGetValue(key, out var value) ? value : null; +} diff --git a/src/Daqifi.Core/Device/Diagnostics/StreamStatsParser.cs b/src/Daqifi.Core/Device/Diagnostics/StreamStatsParser.cs new file mode 100644 index 00000000..e66d7373 --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/StreamStatsParser.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Daqifi.Core.Device.Diagnostics; + +/// +/// Parses the response from the SYSTem:STReam:STATS? SCPI query into a . +/// +/// +/// The firmware returns a set of Key=Value lines (see ). +/// Parsing succeeds when at least one counter is recognized. +/// +public static class StreamStatsParser +{ + /// + /// Attempts to parse streaming stats from a sequence of response lines. + /// + /// Response lines from the device. + /// The parsed stats, or if no counter could be parsed. + /// if at least one counter was parsed; otherwise . + public static bool TryParse(IEnumerable lines, [NotNullWhen(true)] out StreamStats? result) + { + var values = KeyValueResponseParser.Parse(lines); + if (values.Count == 0) + { + result = null; + return false; + } + + result = new StreamStats { Values = values }; + return true; + } +} diff --git a/src/Daqifi.Core/Device/Diagnostics/SystemLogEntry.cs b/src/Daqifi.Core/Device/Diagnostics/SystemLogEntry.cs new file mode 100644 index 00000000..a26de6c9 --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/SystemLogEntry.cs @@ -0,0 +1,18 @@ +namespace Daqifi.Core.Device.Diagnostics; + +/// +/// A single entry from the device system log, returned by the SYSTem:LOG? SCPI query. +/// +/// +/// The firmware stores log entries as free-form text and does not currently prefix them with a +/// structured level, module, or timestamp, so only the raw is exposed. +/// Additional parsed fields may be added in a future firmware/library revision without breaking +/// this type (it uses init-only properties rather than positional record parameters). +/// +public sealed record SystemLogEntry +{ + /// + /// Gets the log message text (trimmed of trailing line endings). + /// + public required string Message { get; init; } +} diff --git a/src/Daqifi.Core/Device/Diagnostics/SystemLogParser.cs b/src/Daqifi.Core/Device/Diagnostics/SystemLogParser.cs new file mode 100644 index 00000000..f278f496 --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/SystemLogParser.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; + +namespace Daqifi.Core.Device.Diagnostics; + +/// +/// Parses the response from the SYSTem:LOG? SCPI query into objects. +/// +/// +/// The firmware dumps the log buffer as free-form text, one entry per line. Blank lines and SCPI +/// error/status lines (e.g. a **ERROR response if the query itself failed) are dropped; every +/// other line becomes one with its trailing line ending trimmed. +/// +public static class SystemLogParser +{ + /// + /// Parses log response lines into entries. + /// + /// The raw response lines from the device. + /// The parsed log entries, in the order returned by the device (oldest first). + /// Thrown when is null. + public static IReadOnlyList Parse(IEnumerable lines) + { + if (lines == null) + { + throw new ArgumentNullException(nameof(lines)); + } + + var entries = new List(); + + foreach (var rawLine in lines) + { + if (string.IsNullOrWhiteSpace(rawLine)) + { + continue; + } + + var message = rawLine.Trim(); + + if (ScpiResponseClassifier.IsErrorResponseLine(message)) + { + continue; + } + + entries.Add(new SystemLogEntry { Message = message }); + } + + return entries; + } +}