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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ DAQiFi builds wireless data acquisition hardware designed to get out of the way

Prefer a ready-made GUI? Check out [DAQiFi Desktop](https://github.com/daqifi/daqifi-desktop), which is built on top of this library.

Want to drive a device from an AI assistant? The repo also ships an **[MCP server](src/Daqifi.Mcp)** — point Claude, Cursor, Codex, or any MCP-aware client at it to discover, configure channels, drive digital I/O and PWM outputs, set the sample rate, and run SD-card logging through plain conversation.
Want to drive a device from an AI assistant? The repo also ships an **[MCP server](src/Daqifi.Mcp)** — point Claude, Cursor, Codex, or any MCP-aware client at it to discover, configure channels, drive digital I/O and PWM outputs, set the sample rate, and run SD-card logging — then list, download, and CSV the recorded data back — through plain conversation.

## See it in 30 seconds

Expand Down Expand Up @@ -70,7 +70,7 @@ More examples at [daqifi.com](https://daqifi.com).
| Hardware | Nyquist 1 / Nyquist 3 — wireless DAQ devices (and their on-device firmware) |
| **SDK** | **DAQiFi Core — this library** |
| App | [DAQiFi Desktop](https://github.com/daqifi/daqifi-desktop) — GUI built on this SDK |
| Agent | [MCP server](src/Daqifi.Mcp) — drive a device from Claude / Cursor / any MCP client: discover, configure channels, DIO/PWM, and SD logging |
| Agent | [MCP server](src/Daqifi.Mcp) — drive a device from Claude / Cursor / any MCP client: discover, configure channels, DIO/PWM, SD logging, and SD data retrieval |
| Your code | Custom apps, dashboards, pipelines, test rigs |

## What you can do
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Daqifi.Core.Device;
using Daqifi.Core.Device.SdCard;
using Xunit;

namespace Daqifi.Core.Tests.Device.SdCard;

/// <summary>
/// Tests for building an <see cref="SdCardDeviceConfiguration"/> from a live device.
/// </summary>
public class SdCardDeviceConfigurationTests
{
[Fact]
public void FromDevice_NullDevice_Throws()
{
Assert.Throws<ArgumentNullException>(() => SdCardDeviceConfiguration.FromDevice(null!));
}

[Fact]
public void FromDevice_ReportsAnalogAndDigitalCounts()
{
var device = new DaqifiDevice("TestDevice");
device.PopulateChannelsFromStatus(new DaqifiOutMessage { AnalogInPortNum = 4, DigitalPortNum = 2 });

var config = SdCardDeviceConfiguration.FromDevice(device);

Assert.NotNull(config);
Assert.Equal(4, config.AnalogPortCount);
Assert.Equal(2, config.DigitalPortCount);
}

/// <summary>
/// The natural moment to build one of these is right before parsing a download — on whichever
/// thread the caller is on, while the device's consumer thread is still decoding status
/// messages and repopulating the channel collection. Folding the live <c>Channels</c> view
/// there throws "Collection was modified"; the snapshot exists so it cannot.
/// </summary>
[Fact]
public async Task FromDevice_WhileStatusMessagesRepopulateChannels_DoesNotThrow()
{
var device = new DaqifiDevice("TestDevice");
var status = new DaqifiOutMessage { AnalogInPortNum = 16, DigitalPortNum = 16, TimestampFreq = 42_000_000 };
device.PopulateChannelsFromStatus(status);

using var stop = new CancellationTokenSource();
Exception? failure = null;

// Stands in for the consumer thread: repopulating clears and refills the backing list, so
// an enumeration of the live view that spans it observes a half-built collection.
var repopulate = Task.Run(() =>
{
while (!stop.IsCancellationRequested)
{
device.PopulateChannelsFromStatus(status);
}
});

try
{
var watch = Stopwatch.StartNew();
for (var i = 0; i < 5_000 && failure is null && watch.Elapsed < TimeSpan.FromSeconds(5); i++)
{
try
{
Assert.NotNull(SdCardDeviceConfiguration.FromDevice(device));
}
catch (Exception ex)
{
failure = ex;
}
}
}
finally
{
stop.Cancel();
await repopulate;
}

Assert.Null(failure);
}
}
13 changes: 11 additions & 2 deletions src/Daqifi.Core/Device/SdCard/SdCardDeviceConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,22 @@ public sealed record SdCardDeviceConfiguration(
/// <returns>A configuration snapshot, or <c>null</c> if the device has no analog channels.</returns>
public static SdCardDeviceConfiguration? FromDevice(DaqifiDevice device)
{
var analogChannels = device.Channels.OfType<IAnalogChannel>().ToList();
ArgumentNullException.ThrowIfNull(device);

// Taken through GetChannelsSnapshot rather than the live Channels view: this runs on
// whatever thread the caller is on (a download about to be parsed, an MCP tool dispatch)
// while the device's consumer thread can be repopulating the collection from a status
// message. Folding the live view there is exactly what DaqifiDevice.Channels documents as
// unsafe — it throws "Collection was modified" mid-enumeration. One snapshot also makes
// the analog and digital counts below describe the same instant instead of two.
var channels = device.GetChannelsSnapshot();
var analogChannels = channels.OfType<IAnalogChannel>().ToList();
if (analogChannels.Count == 0)
{
return null;
}

var digitalCount = device.Channels.Count(c => c.Type == ChannelType.Digital);
var digitalCount = channels.Count(c => c.Type == ChannelType.Digital);
var firstAnalog = analogChannels[0];

return new SdCardDeviceConfiguration(
Expand Down
Loading