diff --git a/.claude/skills/hil-smoke-test/SKILL.md b/.claude/skills/hil-smoke-test/SKILL.md new file mode 100644 index 0000000..5dba78d --- /dev/null +++ b/.claude/skills/hil-smoke-test/SKILL.md @@ -0,0 +1,89 @@ +--- +name: hil-smoke-test +description: Run the hardware-in-the-loop smoke test against a USB-connected DAQiFi device — discover, connect, read metadata, stream briefly, verify samples flow, disconnect cleanly. Use after touching the SDK to confirm real-device integration still works. Triggers on "smoke test the device", "run hil test", "test with hardware", "verify device integration", "/hil", "/smoke-test". +user-invocable: true +allowed-tools: + - Bash(dotnet build *) + - Bash(dotnet run *) + - Bash(ls *) + - Read +--- + +# /hil-smoke-test — Hardware-in-the-Loop Smoke Test + +A quick sanity check that the SDK still talks to a real DAQiFi device over +USB after you've made changes. Read-only with respect to persistent device +state (no SD card writes, no network reconfig, no firmware updates). + +Your job here is **orchestration** — build, run the test, interpret the +exit code, help diagnose failures. The actual test logic lives in +[src/Daqifi.Core.SmokeTest/Program.cs](../../../src/Daqifi.Core.SmokeTest/Program.cs) +and is deterministic — do **not** reimplement it inline or instruct the +user to copy-paste C# into a REPL. Always run the binary. + +## How to run it + +Build then run, in two separate commands so the user sees compile errors +distinct from runtime failures: + +```bash +dotnet build src/Daqifi.Core.SmokeTest/Daqifi.Core.SmokeTest.csproj -c Release +dotnet run --project src/Daqifi.Core.SmokeTest -c Release --no-build -- [args] +``` + +Default invocation (auto-discover the USB device, stream 2s at 100 Hz on +channels 0–1) takes no args. If the user mentions a specific port, baud, +duration, or rate, pass them through: + +| Flag | Default | When to pass it | +|---|---|---| +| `--port=` | `auto` | User names a port, or auto-discovery picks the wrong one | +| `--baud=` | `9600` | User has a non-default baud | +| `--rate=` | `100` | User wants higher throughput pressure (try `1000`) | +| `--duration=` | `2` | Longer to surface slow leaks; shorter for tight iteration | +| `--channels=` | `3` | Decimal bitmask, e.g. `255` for 8 channels | + +The smoke-test project targets `net9.0` (the SDK's minimum). No `-f` +flag needed. + +## Interpreting exit codes + +The test exits with a stable code. **Use the code, not the message text** — +the message wording may evolve. + +| Exit | Meaning | Most likely cause | What to suggest | +|---|---|---|---| +| `0` | PASS | — | Report pass cleanly. | +| `2` | Bad arguments | Typo in flag | Show usage and re-run. | +| `10` | No device found | USB not connected / device off / driver missing / charge-only cable | Check power LED, swap cable, confirm `ls /dev/cu.*` shows a DAQiFi entry on macOS, or `ls /dev/ttyACM*` on Linux. | +| `11` | Connect / init failed | Device busy (another process holds the port), firmware mid-boot, bad serial state | Close DAQiFi Desktop / any other app holding the port. Power-cycle the device and retry. | +| `12` | Metadata missing | Device responded but reported empty part number — possibly bootloader / partial init | Power-cycle; if it persists, this is a regression worth investigating in the init path. | +| `13` | Degraded samples | Stream produced fewer samples than the threshold (~50% of `rate × duration`) | Re-run; if it repeats, the streaming or parsing path has regressed. Try a higher `--duration` to rule out cold-start latency. | +| `20` | No samples received | Stream command sent but zero analog data came back | High-signal regression — likely in `EnableAdcChannels`, `StartStreaming`, the protobuf consumer, or `DaqifiOutMessage.AnalogInData` mapping. Check recent diffs in `src/Daqifi.Core/Communication/Consumers/` and `Device/DaqifiDevice.cs`. | +| `99` | Unexpected exception | Bug in the SDK itself escaped to the smoke harness | Show the stack trace from stderr. | + +## When you should re-run before declaring a failure + +HIL tests are inherently a little flaky. **Re-run once** on exit codes `10`, +`11`, or `13` before reporting failure — USB enumeration timing, DTR +toggle races, and brief device-side latency can all produce one-shot +hiccups. Do **not** retry exit code `0`, `2`, `12`, `20`, or `99` — those +are deterministic states. + +## What to report back to the user + +After a successful run, summarize in one or two lines: device that was +tested (part number + serial), and sample throughput. Don't paste the full +stdout — the user can scroll if they want detail. + +After a failure, lead with the exit code's meaning, then the most likely +cause from the table above, then ask what they want to do next. Don't +auto-spawn investigation tasks unless the user asks — failures here can +often be physical (cable, power) and a side-task wastes effort. + +## Out of scope for this skill + +If the user asks the smoke test to also exercise SD card operations, +firmware updates, or network reconfiguration: **decline and explain why**. +Each of those is destructive or hardware-modifying and belongs in its own +opt-in skill, not a smoke test. Offer to design one separately. diff --git a/Daqifi.Core.sln b/Daqifi.Core.sln index a14b915..60366ae 100644 --- a/Daqifi.Core.sln +++ b/Daqifi.Core.sln @@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Daqifi.Core", "src\Daqifi.C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Daqifi.Core.Tests", "src\Daqifi.Core.Tests\Daqifi.Core.Tests.csproj", "{A3E04AD3-E095-48CD-99C7-BD82EF2AB128}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Daqifi.Core.SmokeTest", "src\Daqifi.Core.SmokeTest\Daqifi.Core.SmokeTest.csproj", "{FFB51BF3-CB42-4788-8D8D-36429FB2FC03}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,9 +28,14 @@ Global {A3E04AD3-E095-48CD-99C7-BD82EF2AB128}.Debug|Any CPU.Build.0 = Debug|Any CPU {A3E04AD3-E095-48CD-99C7-BD82EF2AB128}.Release|Any CPU.ActiveCfg = Release|Any CPU {A3E04AD3-E095-48CD-99C7-BD82EF2AB128}.Release|Any CPU.Build.0 = Release|Any CPU + {FFB51BF3-CB42-4788-8D8D-36429FB2FC03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFB51BF3-CB42-4788-8D8D-36429FB2FC03}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFB51BF3-CB42-4788-8D8D-36429FB2FC03}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFB51BF3-CB42-4788-8D8D-36429FB2FC03}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F30FE70B-E858-46F1-8BDA-16859680FE56} = {5412868F-5B63-486D-8EA6-ED8C83418517} {A3E04AD3-E095-48CD-99C7-BD82EF2AB128} = {5412868F-5B63-486D-8EA6-ED8C83418517} + {FFB51BF3-CB42-4788-8D8D-36429FB2FC03} = {5412868F-5B63-486D-8EA6-ED8C83418517} EndGlobalSection EndGlobal diff --git a/src/Daqifi.Core.SmokeTest/Daqifi.Core.SmokeTest.csproj b/src/Daqifi.Core.SmokeTest/Daqifi.Core.SmokeTest.csproj new file mode 100644 index 0000000..6e0bb4e --- /dev/null +++ b/src/Daqifi.Core.SmokeTest/Daqifi.Core.SmokeTest.csproj @@ -0,0 +1,17 @@ + + + + Exe + net9.0 + enable + enable + false + Daqifi.Core.SmokeTest + Daqifi.Core.SmokeTest + + + + + + + diff --git a/src/Daqifi.Core.SmokeTest/Program.cs b/src/Daqifi.Core.SmokeTest/Program.cs new file mode 100644 index 0000000..ca2dad8 --- /dev/null +++ b/src/Daqifi.Core.SmokeTest/Program.cs @@ -0,0 +1,352 @@ +using System.Diagnostics; +using System.Globalization; +using System.Numerics; +using Daqifi.Core.Communication.Messages; +using Daqifi.Core.Communication.Producers; +using Daqifi.Core.Device; +using Daqifi.Core.Device.Discovery; +using DeviceType = Daqifi.Core.Device.DeviceType; + +namespace Daqifi.Core.SmokeTest; + +/// +/// Hardware-in-the-loop smoke test for a USB-connected DAQiFi device. +/// +/// Designed for a developer-local sanity check after touching the SDK: +/// discover → connect → read metadata → enable channels → stream briefly +/// → stop → disconnect. Read-only with respect to persistent device state +/// (no SD card writes, no network config changes, no firmware updates). +/// +/// Exits with a named, stable code so a wrapping skill or CI step can +/// react to the failure mode rather than parsing the message text. +/// +internal static class Program +{ + private const int DefaultBaudRate = 9600; + private const int DefaultStreamRateHz = 100; + private const int DefaultStreamDurationSeconds = 2; + private const int DefaultChannelBitmask = 3; + private const int DiscoveryTimeoutSeconds = 6; + + private static async Task Main(string[] args) + { + Options options; + try + { + options = Options.Parse(args); + } + catch (ArgumentException ex) + { + Console.Error.WriteLine($"Argument error: {ex.Message}"); + PrintUsage(); + return (int)ExitCode.BadArgs; + } + + if (options.ShowHelp) + { + PrintUsage(); + return (int)ExitCode.Success; + } + + var stopwatch = Stopwatch.StartNew(); + try + { + return (int)await RunAsync(options).ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"FAIL unexpected error: {ex.GetType().Name}: {ex.Message}"); + Console.Error.WriteLine(ex.StackTrace); + return (int)ExitCode.Unexpected; + } + finally + { + stopwatch.Stop(); + Console.WriteLine($"Total elapsed: {stopwatch.Elapsed.TotalSeconds:0.00}s"); + } + } + + private static async Task RunAsync(Options options) + { + // ─── Step 1: locate the device ────────────────────────────────────── + IDeviceInfo? deviceInfo; + if (options.PortName == "auto") + { + Console.WriteLine($"Step 1/6 Discovering DAQiFi USB devices (timeout {DiscoveryTimeoutSeconds}s)…"); + using var finder = new SerialDeviceFinder(options.BaudRate); + var discovered = (await finder.DiscoverAsync(TimeSpan.FromSeconds(DiscoveryTimeoutSeconds)) + .ConfigureAwait(false)).ToList(); + + if (discovered.Count == 0) + { + Console.Error.WriteLine("FAIL no DAQiFi device found on any serial port."); + Console.Error.WriteLine(" Check that the device is powered, USB cable is data-capable, and drivers are installed."); + return ExitCode.NoDevice; + } + + if (discovered.Count > 1) + { + Console.WriteLine($" Found {discovered.Count} devices; using the first. Pass --port= to pick one."); + foreach (var d in discovered) + { + Console.WriteLine($" - {d}"); + } + } + + deviceInfo = discovered[0]; + Console.WriteLine($" Discovered: {deviceInfo}"); + } + else + { + Console.WriteLine($"Step 1/6 Using explicit port {options.PortName} (skipping discovery)."); + deviceInfo = null; + } + + // ─── Step 2: connect + initialize ─────────────────────────────────── + // Always go through ConnectSerialAsync(port, baud) — ConnectFromDeviceInfoAsync's + // serial path drops baud back to the SDK default (9600), which would silently + // override the user's --baud when discovery succeeded at the requested rate. + Console.WriteLine("Step 2/6 Connecting and initializing device…"); + DaqifiDevice device; + try + { + var portName = deviceInfo?.PortName ?? options.PortName; + device = await DaqifiDeviceFactory.ConnectSerialAsync(portName, options.BaudRate) + .ConfigureAwait(false); + } + catch (Exception ex) + { + Console.Error.WriteLine($"FAIL connect/init failed: {ex.GetType().Name}: {ex.Message}"); + return ExitCode.ConnectFailed; + } + + // From here on, ensure we always tear down the device and (best-effort) + // stop streaming, even on assertion failure or thrown exceptions — + // otherwise the device is left streaming and the next run starts with + // a torrent of stale samples that masks real regressions. + try + { + // ─── Step 3: read metadata ────────────────────────────────────── + Console.WriteLine("Step 3/6 Reading device metadata…"); + var meta = device.Metadata; + Console.WriteLine($" Part number: {Display(meta.PartNumber)}"); + Console.WriteLine($" Serial number: {Display(meta.SerialNumber)}"); + Console.WriteLine($" Firmware version: {Display(meta.FirmwareVersion)}"); + Console.WriteLine($" Hardware rev: {Display(meta.HardwareRevision)}"); + Console.WriteLine($" Device type: {meta.DeviceType}"); + Console.WriteLine($" Analog inputs: {meta.Capabilities.AnalogInputChannels}"); + + if (string.IsNullOrWhiteSpace(meta.PartNumber) || meta.DeviceType == DeviceType.Unknown) + { + Console.Error.WriteLine("FAIL device initialized but reported empty/unknown part number — initialization did not populate metadata."); + return ExitCode.MetadataMissing; + } + + // ─── Step 4: subscribe + start streaming ──────────────────────── + // Count both protobuf "stream" messages and the analog-in samples + // they carry. A device can emit messages without any analog data + // (e.g. status frames), and a healthy stream produces both — we + // want to be specific that we saw actual ADC data. + var streamMessageCount = 0; + var analogSampleCount = 0; + void OnMessage(object? sender, MessageReceivedEventArgs e) + { + if (e.Message.Data is not DaqifiOutMessage msg) return; + Interlocked.Increment(ref streamMessageCount); + if (msg.AnalogInData.Count > 0) + { + Interlocked.Add(ref analogSampleCount, msg.AnalogInData.Count); + } + } + + device.MessageReceived += OnMessage; + try + { + Console.WriteLine($"Step 4/6 Enabling ADC channels (bitmask={options.ChannelBitmask}, {options.EnabledChannelCount} channels) and starting stream at {options.StreamRateHz} Hz…"); + device.Send(ScpiMessageProducer.EnableAdcChannels(options.ChannelBitmaskScpi)); + device.Send(ScpiMessageProducer.StartStreaming(options.StreamRateHz)); + + // ─── Step 5: stream for N seconds ─────────────────────────── + Console.WriteLine($"Step 5/6 Streaming for {options.StreamDurationSeconds}s…"); + await Task.Delay(TimeSpan.FromSeconds(options.StreamDurationSeconds)).ConfigureAwait(false); + + device.Send(ScpiMessageProducer.StopStreaming); + // Let the producer drain any in-flight bytes before we tear down. + await Task.Delay(200).ConfigureAwait(false); + } + finally + { + device.MessageReceived -= OnMessage; + } + + // ─── Step 6: verify ───────────────────────────────────────────── + Console.WriteLine("Step 6/6 Verifying sample throughput…"); + Console.WriteLine($" Protobuf messages received: {streamMessageCount}"); + Console.WriteLine($" Analog samples received: {analogSampleCount}"); + + // Tolerate ~50% of the theoretical max — USB CDC framing, + // initial channel-enable latency, and the stop window all eat + // into the count. A real broken stream produces zero, not half. + // AnalogInData carries one element per enabled channel per tick, + // so the expected count scales with channel count too. + var expected = options.StreamRateHz + * options.StreamDurationSeconds + * options.EnabledChannelCount; + var minAcceptable = Math.Max(1, expected / 2); + + if (analogSampleCount == 0) + { + Console.Error.WriteLine("FAIL no analog samples received — streaming pipeline is broken."); + return ExitCode.NoSamples; + } + if (analogSampleCount < minAcceptable) + { + Console.Error.WriteLine($"FAIL sample count {analogSampleCount} below threshold {minAcceptable} (expected ~{expected}). Stream is degraded."); + return ExitCode.DegradedSamples; + } + + Console.WriteLine(); + Console.WriteLine("PASS device discovered, connected, initialized, streamed, and stopped cleanly."); + return ExitCode.Success; + } + finally + { + // Best-effort cleanup: stop streaming and dispose. We swallow + // exceptions here because the primary failure mode is already + // reflected in the exit code — we don't want cleanup noise to + // mask the real cause. + try { device.Send(ScpiMessageProducer.StopStreaming); } catch { } + try { device.Disconnect(); } catch { } + try { device.Dispose(); } catch { } + } + } + + private static string Display(string value) => + string.IsNullOrWhiteSpace(value) ? "" : value; + + private static void PrintUsage() + { + Console.WriteLine(""" + Daqifi.Core.SmokeTest — hardware-in-the-loop smoke test (USB/serial) + + Usage: + dotnet run --project src/Daqifi.Core.SmokeTest -- [options] + + Options: + --port= Serial port. 'auto' (default) runs discovery. + --baud= Baud rate. Default: 9600. + --rate= Stream sample rate in Hz. Default: 100. + --duration= Stream duration in seconds. Default: 2. + --channels= Decimal ADC channel bitmask. Default: 3 (channels 0,1). + -h, --help Show this help. + + Exit codes: + 0 success + 2 bad arguments + 10 no device found + 11 connect or init failed + 12 metadata missing or unknown device type + 13 degraded samples (below ~50% expected throughput) + 20 no samples received + 99 unexpected error + """); + } + + private enum ExitCode + { + Success = 0, + BadArgs = 2, + NoDevice = 10, + ConnectFailed = 11, + MetadataMissing = 12, + NoSamples = 20, + DegradedSamples = 13, + Unexpected = 99, + } + + private sealed class Options + { + public string PortName { get; init; } = "auto"; + public int BaudRate { get; init; } = DefaultBaudRate; + public int StreamRateHz { get; init; } = DefaultStreamRateHz; + public int StreamDurationSeconds { get; init; } = DefaultStreamDurationSeconds; + public int ChannelBitmask { get; init; } = DefaultChannelBitmask; + public bool ShowHelp { get; init; } + + public int EnabledChannelCount => BitOperations.PopCount((uint)ChannelBitmask); + public string ChannelBitmaskScpi => ChannelBitmask.ToString(CultureInfo.InvariantCulture); + + public static Options Parse(string[] args) + { + var port = "auto"; + var baud = DefaultBaudRate; + var rate = DefaultStreamRateHz; + var duration = DefaultStreamDurationSeconds; + var channels = DefaultChannelBitmask; + var help = false; + + foreach (var raw in args) + { + var arg = raw.Trim(); + if (arg is "-h" or "--help") + { + help = true; + continue; + } + + var (key, value) = SplitKeyValue(arg); + switch (key) + { + case "--port": + port = RequireValue(key, value); + break; + case "--baud": + baud = ParseInt(key, value, min: 1); + break; + case "--rate": + rate = ParseInt(key, value, min: 1); + break; + case "--duration": + duration = ParseInt(key, value, min: 1); + break; + case "--channels": + channels = ParseInt(key, value, min: 1); + break; + default: + throw new ArgumentException($"unknown argument '{arg}'"); + } + } + + return new Options + { + PortName = port, + BaudRate = baud, + StreamRateHz = rate, + StreamDurationSeconds = duration, + ChannelBitmask = channels, + ShowHelp = help, + }; + } + + private static (string Key, string? Value) SplitKeyValue(string arg) + { + var eq = arg.IndexOf('='); + return eq < 0 ? (arg, null) : (arg[..eq], arg[(eq + 1)..]); + } + + private static string RequireValue(string key, string? value) => + string.IsNullOrWhiteSpace(value) + ? throw new ArgumentException($"{key} requires a value (e.g. {key}=value)") + : value!; + + private static int ParseInt(string key, string? value, int min) + { + var raw = RequireValue(key, value); + if (!int.TryParse(raw, out var n) || n < min) + { + throw new ArgumentException($"{key} must be an integer >= {min} (got '{raw}')"); + } + return n; + } + } +}