Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions docs/DEVICE_INTERFACES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<SystemLogEntry> 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<string> 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArgumentException>(() => 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<ArgumentException>(() => ScpiMessageProducer.SetLogLevel(module, 1));
}

[Theory]
[InlineData(-1)]
[InlineData(4)]
public void SetLogLevel_WithLevelOutOfRange_Throws(int level)
{
Assert.Throws<ArgumentOutOfRangeException>(() => ScpiMessageProducer.SetLogLevel("STREAM", level));
}

private static void AssertMessageFormat(IOutboundMessage<string> message)
{
var bytes = message.GetBytes();
Expand Down
Original file line number Diff line number Diff line change
@@ -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 + "<n>: <command>" 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<string>()));
}

[Fact]
public void Parse_WhenNull_Throws()
{
Assert.Throws<ArgumentNullException>(() => CommandHistoryParser.Parse(null!));
}
}
Loading