diff --git a/README.md b/README.md index 9a8408a..f01464e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardDeviceConfigurationTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardDeviceConfigurationTests.cs new file mode 100644 index 0000000..5b74b71 --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardDeviceConfigurationTests.cs @@ -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; + +/// +/// Tests for building an from a live device. +/// +public class SdCardDeviceConfigurationTests +{ + [Fact] + public void FromDevice_NullDevice_Throws() + { + Assert.Throws(() => 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); + } + + /// + /// 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 Channels view + /// there throws "Collection was modified"; the snapshot exists so it cannot. + /// + [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); + } +} diff --git a/src/Daqifi.Core/Device/SdCard/SdCardDeviceConfiguration.cs b/src/Daqifi.Core/Device/SdCard/SdCardDeviceConfiguration.cs index 16ff44f..883c227 100644 --- a/src/Daqifi.Core/Device/SdCard/SdCardDeviceConfiguration.cs +++ b/src/Daqifi.Core/Device/SdCard/SdCardDeviceConfiguration.cs @@ -47,13 +47,22 @@ public sealed record SdCardDeviceConfiguration( /// A configuration snapshot, or null if the device has no analog channels. public static SdCardDeviceConfiguration? FromDevice(DaqifiDevice device) { - var analogChannels = device.Channels.OfType().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().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( diff --git a/src/Daqifi.Mcp.Tests/SdCardToolsTests.cs b/src/Daqifi.Mcp.Tests/SdCardToolsTests.cs new file mode 100644 index 0000000..8d7d657 --- /dev/null +++ b/src/Daqifi.Mcp.Tests/SdCardToolsTests.cs @@ -0,0 +1,532 @@ +using System.Text; +using Daqifi.Core.Device.SdCard; +using Daqifi.Core.Logging.Export; + +namespace Daqifi.Mcp.Tests; + +/// +/// Contract tests for the SD-card retrieval tools (#500) — the half of the SD surface an agent +/// needs to get data back off the card, as opposed to starting a log it can never read. +/// +/// +/// No device is attached here, so what these pin is everything the tools decide before (and +/// after) the wire: which calls a --read-only server refuses, what a caller is told when +/// nothing is connected, and the parse/export chain that turns a downloaded log into a CSV. +/// +public class SdCardAgentGuardTests +{ + private static DaqifiAgent NewAgent(bool readOnly = false) => + new(new ServerOptions { ReadOnly = readOnly }); + + [Fact] + public async Task ListSdFiles_UnknownDevice_PointsAtConnectDevice() + { + var ex = await Assert.ThrowsAsync( + () => NewAgent().ListSdFilesAsync("serial:NOPE", CancellationToken.None)); + Assert.Contains("connect_device", ex.Message); + } + + [Fact] + public async Task GetSdStorage_UnknownDevice_PointsAtConnectDevice() + { + var ex = await Assert.ThrowsAsync( + () => NewAgent().GetSdStorageAsync("serial:NOPE", CancellationToken.None)); + Assert.Contains("connect_device", ex.Message); + } + + [Fact] + public async Task DownloadSdFile_UnknownDevice_PointsAtConnectDevice() + { + var ex = await Assert.ThrowsAsync( + () => NewAgent().DownloadSdFileAsync("serial:NOPE", "log.bin", exportCsv: true, CancellationToken.None)); + Assert.Contains("connect_device", ex.Message); + } + + // The whole point of --read-only is that a caller can still LOOK. Retrieval reads device data + // and changes nothing on the card, so it must not be swept up with the mutating tools; these + // three fail for want of a device, never for want of permission. + [Theory] + [InlineData("list")] + [InlineData("storage")] + [InlineData("download")] + public async Task Retrieval_IsStillAvailableInReadOnlyMode(string operation) + { + var agent = NewAgent(readOnly: true); + + Task Call() => operation switch + { + "list" => agent.ListSdFilesAsync("serial:NOPE", CancellationToken.None), + "storage" => agent.GetSdStorageAsync("serial:NOPE", CancellationToken.None), + _ => agent.DownloadSdFileAsync("serial:NOPE", "log.bin", exportCsv: true, CancellationToken.None), + }; + + var ex = await Assert.ThrowsAsync(Call); + Assert.DoesNotContain("read-only", ex.Message); + Assert.Contains("not connected", ex.Message); + } + + [Fact] + public async Task DeleteSdFile_InReadOnlyMode_IsRefusedBeforeAnythingElse() + { + // Deliberately a device id that does not exist: the read-only refusal has to win, or a + // caller could discover that permission was never the obstacle only after connecting. + var ex = await Assert.ThrowsAsync( + () => NewAgent(readOnly: true).DeleteSdFileAsync("serial:NOPE", "log.bin", CancellationToken.None)); + Assert.Contains("read-only", ex.Message); + } + + [Fact] + public async Task DeleteSdFile_WithControl_UnknownDevice_PointsAtConnectDevice() + { + var ex = await Assert.ThrowsAsync( + () => NewAgent().DeleteSdFileAsync("serial:NOPE", "log.bin", CancellationToken.None)); + Assert.Contains("connect_device", ex.Message); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task DownloadSdFile_BlankFileName_PointsAtListSdFiles(string fileName) + { + var ex = await Assert.ThrowsAsync( + () => NewAgent().DownloadSdFileAsync("serial:NOPE", fileName, exportCsv: true, CancellationToken.None)); + Assert.Contains("list_sd_files", ex.Message); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task DeleteSdFile_BlankFileName_PointsAtListSdFiles(string fileName) + { + var ex = await Assert.ThrowsAsync( + () => NewAgent().DeleteSdFileAsync("serial:NOPE", fileName, CancellationToken.None)); + Assert.Contains("list_sd_files", ex.Message); + } + + // Core rejects these before putting a name into an SCPI command, but the listing pre-flight + // runs first — so without the same check here a name full of newlines comes back as a + // multi-line "there is no file called ..." instead of something a caller can act on. + [Theory] + [InlineData("log\n.bin")] + [InlineData("log\r.bin")] + [InlineData("log\t.bin")] + [InlineData("log\".bin")] + [InlineData("log;rm.bin")] + [InlineData("log\u0000.bin")] + public async Task FileNamesWithControlOrCommandCharacters_AreRejectedCleanly(string fileName) + { + var ex = await Assert.ThrowsAsync( + () => NewAgent().DownloadSdFileAsync("serial:NOPE", fileName, exportCsv: true, CancellationToken.None)); + + Assert.Contains("not a valid SD file name", ex.Message); + Assert.DoesNotContain("\n", ex.Message); + Assert.DoesNotContain("\r", ex.Message); + + var deleteEx = await Assert.ThrowsAsync( + () => NewAgent().DeleteSdFileAsync("serial:NOPE", fileName, CancellationToken.None)); + Assert.Contains("not a valid SD file name", deleteEx.Message); + } + + [Fact] + public async Task OrdinaryFileNames_AreNotCaughtByTheCharacterCheck() + { + // Spaces, dots, dashes and underscores all appear in real on-card names; the check must + // only be looking for the characters that break an SCPI command or an error message. + foreach (var name in new[] { "log_20260812_120000.bin", "iso A 1.bin", "bench-pr2.json" }) + { + var ex = await Assert.ThrowsAsync( + () => NewAgent().DownloadSdFileAsync("serial:NOPE", name, exportCsv: true, CancellationToken.None)); + Assert.Contains("not connected", ex.Message); + } + } +} + +public class SdCardReportDtoTests +{ + [Fact] + public void SdStorageReport_ComputesPercentFreeAndUsed() + { + var report = SdStorageReport.From("serial:X", new SdCardStorageInfo(FreeBytes: 250, TotalBytes: 1000)); + + Assert.Equal(250, report.FreeBytes); + Assert.Equal(750, report.UsedBytes); + Assert.Equal(25.0, report.PercentFree); + } + + [Fact] + public void SdStorageReport_ZeroTotal_DoesNotDivideByZero() + { + var report = SdStorageReport.From("serial:X", new SdCardStorageInfo(FreeBytes: 0, TotalBytes: 0)); + Assert.Equal(0, report.PercentFree); + } + + // A listing entry with no size token means "unknown", and 0 means "empty file" — the two are + // different enough that Core keeps the distinction (an unexpectedly-0-byte transfer is how a + // wedged SD subsystem announces itself), so the tool must not flatten it. + [Fact] + public void SdFileEntry_UnknownSize_StaysNullRatherThanZero() + { + var entry = SdFileEntry.From(new SdCardFileInfo("log_20260812_120000.bin")); + Assert.Null(entry.SizeBytes); + + var empty = SdFileEntry.From(new SdCardFileInfo("empty.bin", createdDate: null, sizeInBytes: 0)); + Assert.Equal(0, empty.SizeBytes); + } +} + +/// +/// Tests for the adapter that feeds a parsed SD log to Core's CSV exporter. +/// +public class SdCardSampleSourceTests +{ + private static SdCardLogEntry Entry(long ticks, uint digital, params double[] analog) => + new(new DateTime(ticks, DateTimeKind.Utc), analog, digital, null); + + private static async IAsyncEnumerable Entries(params SdCardLogEntry[] entries) + { + foreach (var entry in entries) + { + yield return entry; + } + await Task.CompletedTask; + } + + [Fact] + public void Channels_AreOnePerAnalogPortPlusTheDigitalPort() + { + var source = new SdCardSampleSource(Entries(), "SN123", analogPortCount: 3); + var channels = source.GetChannels(); + + Assert.Equal(4, channels.Count); + Assert.Equal(new[] { "AI0", "AI1", "AI2", "DIO" }, channels.Select(c => c.ChannelName)); + Assert.All(channels, c => Assert.Contains("SN123", c.Key)); + } + + [Fact] + public void NoAnalogPorts_StillExportsTheDigitalColumn() + { + // CsvExporter returns without writing anything when a source has no channels, so an + // analog-less device has to keep at least one column or the export silently produces + // an empty file. + Assert.Single(new SdCardSampleSource(Entries(), "SN123", analogPortCount: 0).GetChannels()); + } + + [Fact] + public async Task RowCount_CountsTimestamps_WhileSampleCountCountsEntries() + { + var source = new SdCardSampleSource( + Entries( + Entry(1000, 0b01, 1.0, 2.0), + Entry(1000, 0b10, 3.0, 4.0), // same timestamp — merges into the first row + Entry(2000, 0b11, 5.0, 6.0)), + "SN123", + analogPortCount: 2); + + var rows = new List(); + await foreach (var row in source.StreamSamples()) + { + rows.Add(row); + } + + Assert.Equal(9, rows.Count); // 3 entries x (2 analog + 1 digital) + Assert.Equal(3, source.SampleCount); + Assert.Equal(2, source.RowCount); + Assert.Equal(0, source.DroppedAnalogColumns); + } + + [Fact] + public async Task MoreAnalogValuesThanChannels_IsTruncatedAndReported() + { + var source = new SdCardSampleSource( + Entries(Entry(1000, 0, 1.0, 2.0, 3.0)), + "SN123", + analogPortCount: 1); + + var rows = new List(); + await foreach (var row in source.StreamSamples()) + { + rows.Add(row); + } + + Assert.Equal(2, rows.Count); // AI0 + DIO; the two extra values have nowhere to go + Assert.Equal(2, source.DroppedAnalogColumns); + } + + [Fact] + public async Task FewerAnalogValuesThanChannels_LeavesTheRemainingColumnsEmpty() + { + var source = new SdCardSampleSource( + Entries(Entry(1000, 0, 1.0)), + "SN123", + analogPortCount: 3); + + var rows = new List(); + await foreach (var row in source.StreamSamples()) + { + rows.Add(row); + } + + Assert.Equal(2, rows.Count); + Assert.Equal(0, source.DroppedAnalogColumns); + } + + // The row count is reported to the agent as "how many lines are in your CSV", so it is only + // worth anything if it matches what CsvExporter actually writes. Run the real exporter and + // count the lines rather than trusting the rule the counter was written against. + [Fact] + public async Task RowCount_MatchesTheLinesTheRealExporterWrites() + { + var source = new SdCardSampleSource( + Entries( + Entry(1000, 0b01, 1.0, 2.0), + Entry(1000, 0b10, 3.0, 4.0), + Entry(2000, 0b11, 5.0, 6.0), + Entry(3000, 0b00, 7.0, 8.0)), + "SN123", + analogPortCount: 2); + + var writer = new StringWriter(); + await new CsvExporter().ExportAsync(source, writer, new CsvExportOptions { UseRelativeTime = false }); + + var lines = writer.ToString() + .Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(l => l.TrimEnd('\r')) + .ToList(); + + Assert.Equal(source.RowCount, lines.Count - 1); // minus the header + Assert.StartsWith("Time,", lines[0]); + Assert.Contains("AI0", lines[0]); + Assert.Contains("DIO", lines[0]); + } +} + +/// +/// End-to-end tests for the download's parse-and-export step, driven with a synthetic on-disk log +/// so the whole chain below the wire — format detection, parse, CSV write, counts — is covered +/// without a device. +/// +public class SdCardCsvExportTests : IDisposable +{ + private readonly string _directory = + Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), $"daqifi_mcp_test_{Guid.NewGuid():N}")).FullName; + + public void Dispose() + { + try { Directory.Delete(_directory, recursive: true); } catch { /* best effort */ } + GC.SuppressFinalize(this); + } + + /// Writes a firmware-shaped SD CSV log with one analog channel and the digital port. + private string WriteLog(string localFileName, int sampleCount) + { + var content = new StringBuilder() + .Append("# Device: Nyquist 1\n") + .Append("# Serial Number: SN500\n") + .Append("# Timestamp Tick Rate: 100 Hz\n") + .Append("ain0_ts,ain0_val,dio_ts,dio_val\n"); + + for (var i = 0; i < sampleCount; i++) + { + var tick = 1000 + (i * 100); + content.Append($"{tick},{i + 1}.0,{tick},{i % 2}\n"); + } + + var path = Path.Combine(_directory, localFileName); + File.WriteAllText(path, content.ToString()); + return path; + } + + [Fact] + public async Task WritesTheCsvNextToTheDownloadAndCountsIt() + { + var path = WriteLog("daqifi_abc123.bin", sampleCount: 4); + + var (csvPath, rows, samples, warning) = await DaqifiAgent.ExportCsvAsync( + path, "log_20260812_120000.csv", liveConfig: null, CancellationToken.None); + + Assert.True(File.Exists(csvPath)); + Assert.Equal(4, samples); + Assert.Equal(4, rows); + Assert.Null(warning); + + var lines = File.ReadAllLines(csvPath); + Assert.Equal(5, lines.Length); // header + 4 rows + Assert.Contains("AI0", lines[0]); + } + + // Regression: the firmware logs in CSV too, and Core's temp file keeps the device-side + // extension — so deriving the export path by swapping the extension names the very file being + // read, and the export truncates its own input before it can parse it. + [Fact] + public async Task CsvSourceFile_IsNotOverwrittenByItsOwnExport() + { + var path = WriteLog("daqifi_abc123.csv", sampleCount: 4); + var sourceBytes = File.ReadAllBytes(path); + + var (csvPath, _, samples, _) = await DaqifiAgent.ExportCsvAsync( + path, "log_20260812_120000.csv", liveConfig: null, CancellationToken.None); + + Assert.NotEqual(path, csvPath); + Assert.Equal(4, samples); + Assert.Equal(sourceBytes, File.ReadAllBytes(path)); + } + + // The local file is a temp name Core minted; only the device-side name says what the format + // is. Detecting from the local path would make the parse depend on a name nobody chose. + [Fact] + public async Task FormatComesFromTheDeviceSideName_NotTheLocalTempName() + { + var path = Path.Combine(_directory, "daqifi_deadbeef.tmp"); + File.Copy(WriteLog("source.csv", sampleCount: 2), path); + + var (csvPath, _, samples, _) = await DaqifiAgent.ExportCsvAsync( + path, "log_20260812_120000.csv", liveConfig: null, CancellationToken.None); + + Assert.Equal(2, samples); + Assert.True(File.Exists(csvPath)); + } + + [Fact] + public async Task UnsupportedExtension_FailsWithoutWritingAnything() + { + var path = WriteLog("daqifi_abc123.bin", sampleCount: 2); + var before = Directory.GetFiles(_directory); + + var ex = await Assert.ThrowsAsync( + () => DaqifiAgent.ExportCsvAsync(path, "log.dat", liveConfig: null, CancellationToken.None)); + + Assert.Contains("Unsupported file extension", ex.Message); + Assert.Equal(before, Directory.GetFiles(_directory)); + } + + // A header-only CSV looks exactly like a successful export of a file with no data in it, so + // the empty case has to announce itself — and must not leave that misleading file behind. + [Fact] + public async Task EmptyLog_ReportsZeroSamplesAndLeavesNoCsvBehind() + { + var path = WriteLog("daqifi_empty.bin", sampleCount: 0); + + var ex = await Assert.ThrowsAsync( + () => DaqifiAgent.ExportCsvAsync(path, "log_20260812_120000.csv", liveConfig: null, CancellationToken.None)); + + Assert.Contains("zero samples", ex.Message); + Assert.Contains("raw file is still available", ex.Message); + Assert.Equal(new[] { path }, Directory.GetFiles(_directory)); + } +} + +/// +/// Tests for the pre-flight that turns "that file is not on the card" from a 20-second stall the +/// firmware never answers into an immediate, actionable refusal. +/// +public class SdCardFileNameResolutionTests +{ + private static SdCardFileInfo File(string name) => new(name, null, 100); + + [Fact] + public async Task NameInTheCachedListing_IsUsedWithoutRelisting() + { + var sd = new FakeSdCard { Cached = [File("log_a.bin"), File("log_b.bin")] }; + + var resolved = await DaqifiAgent.ResolveFileNameAsync(sd, "log_b.bin", CancellationToken.None); + + Assert.Equal("log_b.bin", resolved); + Assert.Equal(0, sd.ListCalls); + } + + // A file recorded by start_sd_logging since the last listing is genuinely on the card. The + // refresh is what stops the check rejecting it for being absent from a stale snapshot. + [Fact] + public async Task NameMissingFromTheCache_IsFoundByRelisting() + { + var sd = new FakeSdCard { Cached = [File("old.bin")], Fresh = [File("old.bin"), File("just_recorded.bin")] }; + + var resolved = await DaqifiAgent.ResolveFileNameAsync(sd, "just_recorded.bin", CancellationToken.None); + + Assert.Equal("just_recorded.bin", resolved); + Assert.Equal(1, sd.ListCalls); + } + + [Fact] + public async Task NameOnNeitherListing_IsRefusedAndNamesWhatIsThere() + { + var sd = new FakeSdCard { Fresh = [File("log_a.bin"), File("log_b.bin")] }; + + var ex = await Assert.ThrowsAsync( + () => DaqifiAgent.ResolveFileNameAsync(sd, "typo.bin", CancellationToken.None)); + + Assert.Contains("no file named 'typo.bin'", ex.Message); + Assert.Contains("log_a.bin", ex.Message); + Assert.Contains("log_b.bin", ex.Message); + } + + [Fact] + public async Task EmptyCard_SaysSo() + { + var ex = await Assert.ThrowsAsync( + () => DaqifiAgent.ResolveFileNameAsync(new FakeSdCard(), "anything.bin", CancellationToken.None)); + + Assert.Contains("card is empty", ex.Message); + } + + // FAT is case-insensitive, so a caller's spelling should match — and what goes to the firmware + // is the card's own spelling, not the caller's. + [Fact] + public async Task MatchIgnoresCase_AndReturnsTheCardsSpelling() + { + var sd = new FakeSdCard { Cached = [File("LOG_A.BIN")] }; + + Assert.Equal("LOG_A.BIN", await DaqifiAgent.ResolveFileNameAsync(sd, "log_a.bin", CancellationToken.None)); + } + + // The listing is a courtesy. If it cannot be taken, the download itself has to be the thing + // that fails — this check must never be the reason a perfectly downloadable file is refused. + [Fact] + public async Task ListingFailure_LetsTheDownloadProceed() + { + var sd = new FakeSdCard { ListThrows = new SdCardOperationException("busy", []) }; + + Assert.Equal("log_a.bin", await DaqifiAgent.ResolveFileNameAsync(sd, "log_a.bin", CancellationToken.None)); + } + + [Fact] + public async Task Cancellation_IsNotSwallowedByTheListingGuard() + { + var sd = new FakeSdCard { ListThrows = new OperationCanceledException() }; + + await Assert.ThrowsAnyAsync( + () => DaqifiAgent.ResolveFileNameAsync(sd, "log_a.bin", CancellationToken.None)); + } + + private sealed class FakeSdCard : ISdCardOperations + { + public IReadOnlyList Cached { get; init; } = []; + public IReadOnlyList Fresh { get; init; } = []; + public Exception? ListThrows { get; init; } + public int ListCalls { get; private set; } + + public IReadOnlyList SdCardFiles => Cached; + + public Task> GetSdCardFilesAsync(CancellationToken cancellationToken = default) + { + ListCalls++; + return ListThrows is not null ? Task.FromException>(ListThrows) : Task.FromResult(Fresh); + } + +#pragma warning disable CS0067 // the interface requires it; nothing here raises it + public event EventHandler? LowSdSpaceWarning; +#pragma warning restore CS0067 + + public bool IsLoggingToSdCard => false; + public Task GetSdCardStorageAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task CheckSdCardSpaceAsync(SdCardCaptureEstimate? plannedCapture = null, long minimumFreeBytes = SdCardSpaceCheck.DefaultMinimumFreeBytes, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public void SetSdCardMinimumFreeSpace(long bytes) => throw new NotSupportedException(); + public Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task StartSdCardLoggingSessionAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task StopSdCardLoggingAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task DeleteSdCardFileAsync(string fileName, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task FormatSdCardAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task DownloadSdCardFileAsync(string fileName, Stream destinationStream, IProgress? progress = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task DownloadSdCardFileAsync(string fileName, IProgress? progress = null, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + } +} diff --git a/src/Daqifi.Mcp/DaqifiAgent.cs b/src/Daqifi.Mcp/DaqifiAgent.cs index edb1af0..f90264f 100644 --- a/src/Daqifi.Mcp/DaqifiAgent.cs +++ b/src/Daqifi.Mcp/DaqifiAgent.cs @@ -4,6 +4,7 @@ using Daqifi.Core.Device; using Daqifi.Core.Device.Discovery; using Daqifi.Core.Device.SdCard; +using Daqifi.Core.Logging.Export; using Microsoft.Extensions.Logging; namespace Daqifi.Mcp; @@ -77,6 +78,19 @@ public sealed class DaqifiAgent /// internal const int MinDiscoveryTimeoutMs = 1000; + /// + /// Write buffer for the CSV a download exports. An exported CSV is several times the size of + /// the log it came from — a row per timestamp, a column per channel — so it is worth more than + /// the 4 KB a defaults to. + /// + /// + /// Deliberately its own number rather than : that + /// one is how much of the raw log is read at a time, this one is how much CSV is held before + /// it goes to disk. They happen to be equal today, and nothing should have to keep them that + /// way. + /// + private const int CsvWriteBufferBytes = 64 * 1024; + /// /// Clamps a caller-supplied discovery timeout to [, 30_000] /// ms. The floor is derived from measurement, not a guess: the serial identify handshake takes @@ -600,6 +614,371 @@ public async Task StopLoggingAsync(string deviceId, CancellationToken ca }, cancellationToken).ConfigureAwait(false); } + // ------------------------------------------------------------ SD card retrieval + + /// + /// Lists the files on the device's SD card. Read-only: available even under + /// --read-only. + /// + public async Task ListSdFilesAsync(string deviceId, CancellationToken cancellationToken) + { + var device = Require(deviceId); + var sd = RequireSdCard(device); + + IReadOnlyList files; + try + { + files = await sd.GetSdCardFilesAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (Rewrite(ex, deviceId) is { } rewritten) + { + throw rewritten; + } + + var entries = files.Select(SdFileEntry.From).ToList(); + return new SdFileListing(deviceId, entries.Count, entries); + } + + /// + /// Reports free/used/total space on the device's SD card. Read-only: available even under + /// --read-only. + /// + public async Task GetSdStorageAsync(string deviceId, CancellationToken cancellationToken) + { + var device = Require(deviceId); + var sd = RequireSdCard(device); + + try + { + var storage = await sd.GetSdCardStorageAsync(cancellationToken).ConfigureAwait(false); + return SdStorageReport.From(deviceId, storage); + } + catch (Exception ex) when (Rewrite(ex, deviceId) is { } rewritten) + { + throw rewritten; + } + } + + /// + /// Downloads an SD-card file to this machine and, when is set, + /// parses it and writes a CSV alongside it. + /// + /// + /// Not gated by --read-only: it reads device data and changes nothing on the card. It + /// does write two files into this machine's temp directory, which is the only way the agent + /// can be handed the data at all. + /// + public async Task DownloadSdFileAsync( + string deviceId, string fileName, bool exportCsv, CancellationToken cancellationToken) + { + var name = RequireFileName(fileName); + var device = Require(deviceId); + var sd = RequireSdCard(device); + + // Snapshotted BEFORE the download: the download suspends the protobuf consumer and can + // leave the transport mid-switch on a timeout, so this is the last point the live channel + // state is guaranteed readable. It carries the calibration and — the part that actually + // matters — the timestamp clock, which firmware 3.7.2 and earlier do not write into SD + // logs at all. Without it the parser falls back to a 50 MHz guess against a 42 MHz clock + // and every reconstructed timestamp comes out ~19% fast. + var liveConfig = exportCsv ? SdCardDeviceConfiguration.FromDevice(device) : null; + + name = await ResolveFileNameAsync(sd, name, cancellationToken).ConfigureAwait(false); + + SdCardDownloadResult download; + try + { + download = await sd.DownloadSdCardFileAsync(name, progress: null, cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) when (Rewrite(ex, deviceId) is { } rewritten) + { + throw rewritten; + } + + var localPath = download.FilePath + ?? throw new InvalidOperationException( + $"The download of '{name}' reported no local file path, so there is nothing to read."); + + string? csvPath = null; + long? rowCount = null; + long? sampleCount = null; + string? csvError = null; + + if (exportCsv) + { + try + { + (csvPath, rowCount, sampleCount, csvError) = + await ExportCsvAsync(localPath, name, liveConfig, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + // The raw file is already on disk and took the whole transfer to get there. + // Failing the tool call here would hand back an error and no path, and the agent + // would re-download to discover the same thing. + csvError = ex.Message; + _logger.LogWarning(ex, "CSV export failed for '{FileName}'; the raw download at {Path} is unaffected.", name, localPath); + } + } + + return new SdDownloadReport( + deviceId, + download.FileName, + download.FileSize, + Math.Round(download.Duration.TotalSeconds, 3), + localPath, + csvPath, + rowCount, + sampleCount, + csvError); + } + + /// + /// Deletes a file from the device's SD card. Destructive, so it is refused under + /// --read-only. + /// + public async Task DeleteSdFileAsync( + string deviceId, string fileName, CancellationToken cancellationToken) + { + RequireControl(); + var name = RequireFileName(fileName); + var device = Require(deviceId); + var sd = RequireSdCard(device); + + try + { + await sd.DeleteSdCardFileAsync(name, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (Rewrite(ex, deviceId) is { } rewritten) + { + throw rewritten; + } + + return new SdDeleteResult(deviceId, name); + } + + /// + /// Confirms the file is actually on the card before a transfer is attempted, and returns the + /// name exactly as the card spells it. + /// + /// + /// Asking the firmware for a file that is not there produces no answer at all: the transfer + /// stalls and gives up 20 seconds later with "the device stopped feeding the transfer", which + /// tells the caller to retry the one thing that cannot work. A name that does not appear in a + /// listing is worth failing on immediately, with the names that do. + /// + /// Free when the caller listed first — the check runs against the listing Core already cached. + /// The re-listing on a miss is what keeps it honest: a file recorded since that listing (by + /// start_sd_logging, say) is genuinely on the card, and must not be rejected for being + /// absent from a stale snapshot. + /// + /// + internal static async Task ResolveFileNameAsync( + ISdCardOperations sd, string fileName, CancellationToken cancellationToken) + { + var match = Match(sd.SdCardFiles, fileName); + if (match is not null) + { + return match; + } + + IReadOnlyList files; + try + { + files = await sd.GetSdCardFilesAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + // The listing is a courtesy, not a gate. If it cannot be taken — a busy card, a + // firmware that will not answer — let the download itself be the thing that fails, so + // this check can never be the reason a downloadable file is refused. + return fileName; + } + + match = Match(files, fileName); + if (match is not null) + { + return match; + } + + var available = files.Count == 0 + ? "The card is empty." + : "On the card: " + string.Join(", ", files.Take(20).Select(f => f.FileName)) + + (files.Count > 20 ? $", and {files.Count - 20} more (call list_sd_files for all of them)." : "."); + + throw new InvalidOperationException($"There is no file named '{fileName}' on the SD card. {available}"); + + // Matched without case sensitivity because the card's filesystem is not case-sensitive, + // and the name that comes back is the card's own spelling — the firmware is handed what it + // put in the listing rather than whatever the caller typed. + static string? Match(IReadOnlyList files, string fileName) => files + .FirstOrDefault(f => string.Equals(f.FileName, fileName, StringComparison.OrdinalIgnoreCase)) + ?.FileName; + } + + /// + /// Parses a downloaded log and writes a CSV next to it, returning the CSV path, the two counts + /// the caller reports (CSV lines and log entries), and a warning when the CSV was written but + /// is known to be incomplete. + /// + internal static async Task<(string CsvPath, long Rows, long Samples, string? Warning)> ExportCsvAsync( + string localPath, + string deviceFileName, + SdCardDeviceConfiguration? liveConfig, + CancellationToken cancellationToken) + { + // Format comes from the DEVICE-side name, not the local one: the local file is a temp file + // Core minted, and deriving the format from it would make the parse depend on a name the + // caller never chose. + var format = SdCardFileParserFactory.DetectFormat(deviceFileName); + + var parseOptions = new SdCardParseOptions { ConfigurationOverride = liveConfig }; + + var stream = new FileStream( + localPath, FileMode.Open, FileAccess.Read, FileShare.Read, + bufferSize: parseOptions.BufferSize, useAsync: true); + + await using (stream.ConfigureAwait(false)) + { + var session = await SdCardFileParserFactory + .ParseWithFormatAsync(stream, Path.GetFileName(deviceFileName), format, parseOptions, cancellationToken) + .ConfigureAwait(false); + + // The file's own status message wins; the live device fills in when the log carries + // none. Zero is the honest last resort — a device with no analog channels exports the + // digital column alone rather than inventing width. + var analogCount = session.DeviceConfig?.AnalogPortCount ?? liveConfig?.AnalogPortCount ?? 0; + + var source = new SdCardSampleSource( + session.Samples, + session.DeviceConfig?.DeviceSerialNumber ?? liveConfig?.DeviceSerialNumber, + analogCount); + + // Appended, not swapped: the firmware logs in CSV as well as protobuf and JSON, and + // Core's temp file keeps the device-side extension — so Path.ChangeExtension(".csv") + // would hand back the path of the file being read for a .csv log and truncate the + // download on the way to parsing it. A suffix cannot collide with what it is added to. + var csvPath = localPath + ".csv"; + + // Every failure from here on deletes the file it was writing. A caller that is told + // "no CSV" gets CsvPath=null, so a half-written one left on disk would be litter + // nobody can name — not even to clean it up. The delete runs after the streams have + // been disposed by the blocks below, which is what makes it work on Windows too. + try + { + // useAsync, like the input stream above: CsvExporter writes through WriteAsync for + // every row, and a FileStream opened without it services those on the thread pool + // instead of the OS async path. See CsvWriteBufferBytes for why the buffer is its + // own number and not the parse buffer. + var fileStream = new FileStream( + csvPath, FileMode.Create, FileAccess.Write, FileShare.Read, + bufferSize: CsvWriteBufferBytes, useAsync: true); + await using (fileStream.ConfigureAwait(false)) + { + var writer = new StreamWriter(fileStream); + await using (writer.ConfigureAwait(false)) + { + await new CsvExporter() + .ExportAsync(source, writer, new CsvExportOptions { UseRelativeTime = false }, progress: null, cancellationToken) + .ConfigureAwait(false); + } + } + + if (source.SampleCount == 0) + { + // A header and nothing else reads like a successful export of a file that had + // no data in it. Say so instead, and leave the raw download in place for + // whoever wants to look at why. + throw new InvalidOperationException( + $"'{deviceFileName}' parsed to zero samples, so no CSV was written. The raw file is still available at the returned path."); + } + } + catch + { + TryDelete(csvPath); + throw; + } + + // Reported rather than thrown, and the CSV is kept: the columns that did map are real + // data. Silence is the one unacceptable option — an agent analysing a CSV that quietly + // lost channels has no way to notice. + var warning = source.DroppedAnalogColumns > 0 + ? $"The CSV is incomplete: samples in '{deviceFileName}' carry {analogCount + source.DroppedAnalogColumns} " + + $"analog values but only {analogCount} analog channels are known, so {source.DroppedAnalogColumns} " + + "column(s) were dropped." + : null; + + return (csvPath, source.RowCount, source.SampleCount, warning); + } + } + + private static void TryDelete(string path) + { + try { File.Delete(path); } catch { /* best effort; a leftover temp file is not worth failing over */ } + } + + /// + /// Rewrites the SD-card failures that have an MCP-specific next step into messages naming the + /// tool to call, and lets everything else through untouched (Core's own messages are already + /// written for a human). Used as an exception filter, so a null return leaves the original + /// exception — and its stack — completely unmodified. + /// + private static Exception? Rewrite(Exception ex, string deviceId) => ex switch + { + SdCardBusyException => new InvalidOperationException( + $"Device '{deviceId}' is busy with the SD card, which usually means it is still logging. " + + "Call stop_sd_logging first, then retry.", ex), + + SdCardEmptyTransferException empty => new InvalidOperationException( + $"{empty.Message} This is also what a live-streaming session does to the SD subsystem: " + + "run SD retrieval before streaming in the same connection, or start and stop an SD " + + "recording to re-arm it.", ex), + + _ => null, + }; + + /// + /// Validates a caller-supplied SD file name and returns it trimmed. + /// + /// + /// The character check mirrors the one Core applies before putting a name into an SCPI command + /// (quotes, newlines and semicolons), widened to every control character. It has to happen + /// here, not just in Core: the listing pre-flight runs first and would otherwise answer a name + /// full of newlines with "there is no file called <several lines of garbage>" instead of + /// the plain "that is not a valid file name" the caller can act on. + /// + private static string RequireFileName(string? fileName) + { + var trimmed = fileName?.Trim(); + if (string.IsNullOrEmpty(trimmed)) + { + throw new InvalidOperationException( + "A file name is required. Call list_sd_files to see what is on the card."); + } + + foreach (var c in trimmed) + { + if (char.IsControl(c) || c is '"' or ';') + { + throw new InvalidOperationException( + "That is not a valid SD file name: quotes, semicolons and control characters " + + "(including newlines and tabs) are not allowed. Call list_sd_files and pass a " + + "name exactly as it is listed."); + } + } + + return trimmed; + } + // ------------------------------------------------------------------ shutdown /// diff --git a/src/Daqifi.Mcp/Dtos.cs b/src/Daqifi.Mcp/Dtos.cs index 9766632..5a8ab39 100644 --- a/src/Daqifi.Mcp/Dtos.cs +++ b/src/Daqifi.Mcp/Dtos.cs @@ -175,3 +175,62 @@ public sealed record StartLoggingResult( string Format, int SampleRateHz, IReadOnlyList EnabledAnalogChannels); + +/// +/// One file in the device's SD-card directory listing. and +/// are null when the listing carried neither — a size of 0 is a real +/// (empty) file, which is why an unknown size is null rather than zero. +/// +public sealed record SdFileEntry(string FileName, long? SizeBytes, DateTime? CreatedDate) +{ + public static SdFileEntry From(SdCardFileInfo info) => + new(info.FileName, info.SizeInBytes, info.CreatedDate); +} + +/// +/// The device's SD-card directory listing. An empty list always means an +/// empty card: a device that did not answer the query fails the tool call instead (#396). +/// +public sealed record SdFileListing(string DeviceId, int FileCount, IReadOnlyList Files); + +/// +/// Free/used/total space on the device's SD card. is rounded to one +/// decimal and is 0 when the device reports a total of 0 bytes. +/// +public sealed record SdStorageReport( + string DeviceId, long FreeBytes, long UsedBytes, long TotalBytes, double PercentFree) +{ + public static SdStorageReport From(string deviceId, SdCardStorageInfo info) => new( + deviceId, + info.FreeBytes, + info.UsedBytes, + info.TotalBytes, + info.TotalBytes > 0 ? Math.Round(info.FreeBytes * 100.0 / info.TotalBytes, 1) : 0); +} + +/// +/// Result of downloading an SD-card file to this machine. +/// +/// Where the raw file was written locally (a temporary file owned by this server). +/// Where the CSV was written, or null when CSV export was not requested or failed. +/// CSV lines written — one per distinct timestamp, not one per sample. +/// Log entries read out of the file. +/// +/// What went wrong in the CSV step while the download itself succeeded — an unparseable format, an +/// empty log, or a CSV that was written but is missing columns. Reported rather than thrown so a +/// download that can take minutes is not discarded along with the error. Worth reading even when +/// is set: that combination means the CSV exists but is incomplete. +/// +public sealed record SdDownloadReport( + string DeviceId, + string FileName, + long SizeBytes, + double DurationSeconds, + string FilePath, + string? CsvPath, + long? CsvRowCount, + long? SampleCount, + string? CsvError); + +/// Result of deleting a file from the SD card. +public sealed record SdDeleteResult(string DeviceId, string FileName); diff --git a/src/Daqifi.Mcp/README.md b/src/Daqifi.Mcp/README.md index 007d9f6..6cad3e9 100644 --- a/src/Daqifi.Mcp/README.md +++ b/src/Daqifi.Mcp/README.md @@ -26,9 +26,18 @@ The server speaks MCP over **stdio**, so the client launches it as a subprocess. | `set_sample_rate` | Set sample rate in Hz (ceiling depends on the enabled channel count; over-cap requests are rejected). | | `start_sd_logging` | Start on-device SD logging (**requires a USB/serial connection**). | | `stop_sd_logging` | Stop SD logging. | - -> SD logging is on-device: the device writes to its own SD card. Data does not stream back to the -> agent in this version. +| `list_sd_files` | List the log files on the SD card, with size and creation date. | +| `get_sd_storage` | Free/used/total space on the SD card. | +| `download_sd_file` | Fetch a log file to this machine and (by default) parse it into a CSV. | +| `delete_sd_file` | Delete a file from the SD card. Destructive; blocked by `--read-only`. | + +> SD logging is on-device: the device writes to its own SD card while the log runs. Nothing streams +> back live in this version — you retrieve the data afterwards with `download_sd_file`, which writes +> the raw file and a CSV into this machine's temp directory and returns both paths. +> +> **Retrieve before you stream.** A live streaming session collapses the device's SD buffer +> (firmware #703), after which downloads come back empty until the device is reconnected or another +> SD recording re-arms it. Do the SD work first on a fresh connection. ## Run it @@ -52,6 +61,12 @@ dotnet run --project src/Daqifi.Mcp -h, --help Show help. ``` +`--read-only` blocks anything that changes the device or the card: channel/rate configuration, +DIO/PWM output, start/stop logging, and `delete_sd_file`. Reading data back is still allowed — +`list_sd_files`, `get_sd_storage` and `download_sd_file` all work, since they change nothing on the +device (the download does write its two files into this machine's temp directory, which is the only +way the data can reach the agent at all). + ## Point your agent at it An stdio MCP server is just a command the client launches. Every client config reduces to @@ -85,6 +100,7 @@ During development, point the client at the source build instead: Then plug in a DAQiFi over USB (or join its WiFi) and ask, e.g.: *"Discover my DAQiFi, connect, enable analog channels 0–3 at 1 kHz, and start logging to the SD card."* +*"Stop the log, then download it and tell me the average on AI0."* ## Notes diff --git a/src/Daqifi.Mcp/SdCardSampleSource.cs b/src/Daqifi.Mcp/SdCardSampleSource.cs new file mode 100644 index 0000000..5568271 --- /dev/null +++ b/src/Daqifi.Mcp/SdCardSampleSource.cs @@ -0,0 +1,106 @@ +using System.Runtime.CompilerServices; +using Daqifi.Core.Channel; +using Daqifi.Core.Device.SdCard; +using Daqifi.Core.Logging.Export; + +namespace Daqifi.Mcp; + +/// +/// Adapts a parsed to so Core's +/// can turn a downloaded SD-card log into a CSV the agent can read. +/// Emits one column per analog channel plus one for the digital port (the raw port value). +/// +/// +/// Also counts what the export produced, because the agent is told both numbers and they are not +/// the same thing: is log entries read, is CSV +/// lines written. The exporter collapses every consecutive sharing a +/// timestamp into one line, so a row is a timestamp, not a sample — counting samples would +/// over-report by the channel count. +/// +internal sealed class SdCardSampleSource : ISampleSource +{ + private const string DeviceName = "Daqifi"; + private const string DigitalChannelName = "DIO"; + + private readonly IAsyncEnumerable _samples; + private readonly List _channels; + private readonly string[] _analogKeys; + private readonly string _digitalKey; + + public SdCardSampleSource( + IAsyncEnumerable samples, string? deviceSerialNumber, int analogPortCount) + { + _samples = samples; + var serial = string.IsNullOrWhiteSpace(deviceSerialNumber) ? "unknown" : deviceSerialNumber; + var analogCount = Math.Max(0, analogPortCount); + + _channels = new List(analogCount + 1); + _analogKeys = new string[analogCount]; + for (var i = 0; i < analogCount; i++) + { + var descriptor = new ChannelDescriptor(DeviceName, serial, $"AI{i}", ChannelType.Analog); + _channels.Add(descriptor); + _analogKeys[i] = descriptor.Key; + } + + var digital = new ChannelDescriptor(DeviceName, serial, DigitalChannelName, ChannelType.Digital); + _channels.Add(digital); + _digitalKey = digital.Key; + } + + /// CSV lines the export produced — one per distinct consecutive timestamp. + public long RowCount { get; private set; } + + /// Log entries read from the file. + public long SampleCount { get; private set; } + + /// + /// Analog values seen on a single entry that had nowhere to go, because the entry carried more + /// analog values than the channel count this source was built with. Non-zero means the CSV is + /// missing columns, so the tool reports it instead of quietly truncating. + /// + public int DroppedAnalogColumns { get; private set; } + + public IReadOnlyList GetChannels() => _channels; + + // 0 means "unknown", which tells CsvExporter to skip percentage progress. Knowing the real + // count would mean reading the whole file first, which is the opposite of streaming it. + public ValueTask GetSampleCountAsync(CancellationToken cancellationToken = default) + => ValueTask.FromResult(0); + + public async IAsyncEnumerable StreamSamples( + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + long? currentTicks = null; + + await foreach (var entry in _samples.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + SampleCount++; + + var ticks = entry.Timestamp.Ticks; + + // Mirrors the exporter's own flush rule (a new line whenever the timestamp changes), so + // the count matches the file line for line — including when consecutive entries repeat + // a timestamp and the exporter merges them into one row. + if (currentTicks != ticks) + { + currentTicks = ticks; + RowCount++; + } + + var overflow = entry.AnalogValues.Count - _analogKeys.Length; + if (overflow > DroppedAnalogColumns) + { + DroppedAnalogColumns = overflow; + } + + var count = Math.Min(entry.AnalogValues.Count, _analogKeys.Length); + for (var i = 0; i < count; i++) + { + yield return new SampleRow(ticks, _analogKeys[i], entry.AnalogValues[i]); + } + + yield return new SampleRow(ticks, _digitalKey, entry.DigitalData); + } + } +} diff --git a/src/Daqifi.Mcp/Tools/DaqifiTools.cs b/src/Daqifi.Mcp/Tools/DaqifiTools.cs index ba770f2..19ea652 100644 --- a/src/Daqifi.Mcp/Tools/DaqifiTools.cs +++ b/src/Daqifi.Mcp/Tools/DaqifiTools.cs @@ -136,6 +136,41 @@ public static Task StopSdLogging( CancellationToken cancellationToken = default) => GuardAsync(() => agent.StopLoggingAsync(deviceId, cancellationToken)); + [McpServerTool(Name = "list_sd_files")] + [Description("List the log files on the device's SD card, with size and creation date where the device reports them. An empty list always means an empty card — a device that fails to answer the listing raises an error instead. Available in --read-only mode.")] + public static Task ListSdFiles( + DaqifiAgent agent, + [Description("The device_id to list files on.")] string deviceId, + CancellationToken cancellationToken = default) + => GuardAsync(() => agent.ListSdFilesAsync(deviceId, cancellationToken)); + + [McpServerTool(Name = "get_sd_storage")] + [Description("Report free, used, and total space on the device's SD card. Refused while the device is logging (the SD card is busy) — call stop_sd_logging first. Available in --read-only mode.")] + public static Task GetSdStorage( + DaqifiAgent agent, + [Description("The device_id to inspect.")] string deviceId, + CancellationToken cancellationToken = default) + => GuardAsync(() => agent.GetSdStorageAsync(deviceId, cancellationToken)); + + [McpServerTool(Name = "download_sd_file")] + [Description("Download an SD-card log file to this machine and, by default, parse it into a CSV you can read. Returns the local path of both files plus the sample and CSV row counts (a row is one timestamp, not one sample). Run SD retrieval before any live streaming on the same connection: a stream collapses the device's SD buffer and later downloads come back empty. Filenames come from list_sd_files. Large files take as long as the transfer takes.")] + public static Task DownloadSdFile( + DaqifiAgent agent, + [Description("The device_id to download from.")] string deviceId, + [Description("The on-card file name as list_sd_files reports it (matched without case sensitivity). A name that is not on the card is rejected straight away.")] string fileName, + [Description("Also parse the download and write a CSV next to it (default true). Set false to fetch the raw file only, e.g. when it is large and you just want it on disk. If the parse fails, the download still succeeds and csvError explains why.")] bool exportCsv = true, + CancellationToken cancellationToken = default) + => GuardAsync(() => agent.DownloadSdFileAsync(deviceId, fileName, exportCsv, cancellationToken)); + + [McpServerTool(Name = "delete_sd_file")] + [Description("Permanently delete a file from the device's SD card. There is no undo and no recycle bin — download it first if the data matters. Refused in --read-only mode, and refused while the device is logging.")] + public static Task DeleteSdFile( + DaqifiAgent agent, + [Description("The device_id to delete from.")] string deviceId, + [Description("The on-card file name, exactly as list_sd_files reports it.")] string fileName, + CancellationToken cancellationToken = default) + => GuardAsync(() => agent.DeleteSdFileAsync(deviceId, fileName, cancellationToken)); + // Surface real exception messages (validation + Core errors) to the agent rather than a // generic "An error occurred". Cancellation is allowed to propagate untouched. private static T Guard(Func action)