Skip to content
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ using Daqifi.Core.Channel;

// Connect — transport and device initialization handled for you. The factory returns the base
// DaqifiDevice type, but the constructed instance is always a DaqifiStreamingDevice.
using var device = (DaqifiStreamingDevice)await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);
await using var device = (DaqifiStreamingDevice)await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);

// Subscribe to decoded, per-channel samples
var ai0 = device.GetChannelsSnapshot().First(c => c.Type == ChannelType.Analog && c.ChannelNumber == 0);
Expand Down Expand Up @@ -97,7 +97,7 @@ Pick whichever transport fits your setup — each snippet is a standalone, copy-
**TCP with a resilient retry preset** (5 retries, longer timeouts):

```csharp
using var device = await DaqifiDeviceFactory.ConnectTcpAsync(
await using var device = await DaqifiDeviceFactory.ConnectTcpAsync(
"192.168.1.100", 9760, DeviceConnectionOptions.Resilient);
```

Expand All @@ -106,15 +106,15 @@ using var device = await DaqifiDeviceFactory.ConnectTcpAsync(
```csharp
// Replace with your OS-specific port:
// Windows: "COM3" • macOS: "/dev/cu.usbmodem1" • Linux: "/dev/ttyACM0"
using var device = await DaqifiDeviceFactory.ConnectSerialAsync("COM3");
await using var device = await DaqifiDeviceFactory.ConnectSerialAsync("COM3");
```

**From a discovered device:**

```csharp
using var finder = new WiFiDeviceFinder();
var devices = await finder.DiscoverAsync(TimeSpan.FromSeconds(5));
using var device = await DaqifiDeviceFactory.ConnectFromDeviceInfoAsync(devices.First());
await using var device = await DaqifiDeviceFactory.ConnectFromDeviceInfoAsync(devices.First());
```

### Custom retry options
Expand All @@ -132,7 +132,7 @@ var options = new DeviceConnectionOptions
},
InitializeDevice = true
};
using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760, options);
await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760, options);
```

> **Connecting takes control of the device.** A DAQiFi unit has a single global acquisition, and the
Expand Down
50 changes: 36 additions & 14 deletions docs/DEVICE_INTERFACES.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ using Daqifi.Core.Device;
using Daqifi.Core.Communication.Producers;

// Connect to a device (handles transport, connection, and initialization)
using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);
await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);

// Subscribe to incoming data
device.MessageReceived += (sender, e) =>
Expand Down Expand Up @@ -150,7 +150,7 @@ foreach (var deviceInfo in devices)
// Connect to the first discovered device
if (devices.Any())
{
using var device = await DaqifiDeviceFactory.ConnectFromDeviceInfoAsync(devices.First());
await using var device = await DaqifiDeviceFactory.ConnectFromDeviceInfoAsync(devices.First());
// Device is ready to use
}
```
Expand Down Expand Up @@ -396,29 +396,51 @@ For cases where you need more control over the connection process:
using Daqifi.Core.Device;
using Daqifi.Core.Communication.Transport;

// Create and connect transport manually
// Create and connect transport manually. Every step takes a CancellationToken, so a user who
// cancels stops the attempt where it stands instead of waiting out the retries.
var transport = new TcpStreamTransport("192.168.1.100", 9760);
await transport.ConnectAsync(new ConnectionRetryOptions { MaxAttempts = 3 });
await transport.ConnectAsync(new ConnectionRetryOptions { MaxAttempts = 3 }, cancellationToken);

// Create device with transport
using var device = new DaqifiDevice("My Device", transport);
device.Connect();
await using var device = new DaqifiDevice("My Device", transport);
await device.ConnectAsync(cancellationToken);

// InitializeAsync takes control of the device and stops any stream it was already running.
// Set device.PreserveActiveStream = true first if another session may be streaming — see
// "Connecting stops any stream already running" above.
await device.InitializeAsync();
await device.InitializeAsync(cancellationToken: cancellationToken);

// Now ready to send commands
device.Send(ScpiMessageProducer.GetDeviceInfo);
```

### Connecting and disconnecting without blocking

`ConnectAsync`, `DisconnectAsync` and `DisposeAsync` are the non-blocking forms of `Connect`,
`Disconnect` and `Dispose`. Prefer them on a UI thread: the synchronous disconnect waits (up to ten
seconds) for any command exchange still in flight, which on a UI thread is a visible freeze.

```csharp
// Disposal never blocks the caller — this is the recommended pattern.
await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);
```

A cancelled `ConnectAsync` throws `OperationCanceledException` and leaves nothing half-open — if the
transport had already come up when the cancel landed, it is closed again.

`DisconnectAsync` treats its token differently, and deliberately: cancelling it skips the wait for an
in-flight command exchange and proceeds straight to teardown. It never aborts the disconnect and
never throws `OperationCanceledException`, because a teardown abandoned part-way would leave the
device in an indeterminate state. On return the device is always disconnected.

The synchronous `Connect`, `Disconnect` and `Dispose` remain, unchanged, for existing callers.

### Error Handling

```csharp
try
{
using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);
await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);
device.Send(ScpiMessageProducer.StartStreaming(100));
}
catch (ArgumentOutOfRangeException ex) when (ex.ParamName == "port")
Expand Down Expand Up @@ -485,7 +507,7 @@ Notes:
### Connection Status Monitoring

```csharp
using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);
await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);

device.StatusChanged += (sender, args) =>
{
Expand Down Expand Up @@ -565,7 +587,7 @@ The failures that feed this escalation are also reported individually, as they h
After initialization, device metadata is populated:

```csharp
using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);
await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);

// Access device information
Console.WriteLine($"Part Number: {device.Metadata.PartNumber}");
Expand Down Expand Up @@ -600,7 +622,7 @@ using Daqifi.Core.Device;

// DaqifiDeviceFactory methods return the base DaqifiDevice type, but the constructed instance is
// always a DaqifiStreamingDevice — cast (or pattern-match with `is`) to reach its streaming API.
using var device = (DaqifiStreamingDevice)await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);
await using var device = (DaqifiStreamingDevice)await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);

var ai0 = device.GetChannelsSnapshot().First(c => c.Type == ChannelType.Analog && c.ChannelNumber == 0);
ai0.SampleReceived += (sender, e) =>
Expand Down Expand Up @@ -632,7 +654,7 @@ cancellation and backpressure instead of hand-building an event/queue bridge. Ea
the decoded `IDataSample` with the `IChannel` that produced it.

```csharp
using var device = (DaqifiStreamingDevice)await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);
await using var device = (DaqifiStreamingDevice)await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);

device.EnableChannels(device.GetChannelsSnapshot().Where(c => c.Type == ChannelType.Analog));
device.StreamingFrequency = 100; // Hz
Expand All @@ -655,7 +677,7 @@ call `StopStreaming()` for that. This is additive: `SampleReceived` and `Message
#### Raw protobuf frames

```csharp
using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);
await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);

var sampleCount = 0;
device.MessageReceived += (sender, e) =>
Expand Down Expand Up @@ -767,7 +789,7 @@ to reach these members — the same way as `INetworkConfigurable` below.
```csharp
using Daqifi.Core.Device.Diagnostics;

using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);
await using var device = await DaqifiDeviceFactory.ConnectTcpAsync("192.168.1.100", 9760);

if (device is IDeviceDiagnostics diagnostics)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public async Task ExecuteAsync_SucceedsFirstAttempt_ReportsConnectedOnce()

await ConnectRetryExecutor.ExecuteAsync(
FastRetry(3),
connectAttempt: _ => { attempts++; return Task.CompletedTask; },
connectAttempt: (_, _) => { attempts++; return Task.CompletedTask; },
onAttemptFailed: () => failedCleanups++,
onStatusChanged: (c, e) => statuses.Add((c, e)));

Expand All @@ -49,7 +49,7 @@ public async Task ExecuteAsync_ReceivesResolvedOptions_InConnectAttempt()

await ConnectRetryExecutor.ExecuteAsync(
options,
connectAttempt: o => { seen = o; return Task.CompletedTask; },
connectAttempt: (o, _) => { seen = o; return Task.CompletedTask; },
onAttemptFailed: () => { },
onStatusChanged: (_, _) => { });

Expand All @@ -66,7 +66,7 @@ public async Task ExecuteAsync_NullOptions_UsesSingleNoRetryAttempt()
var thrown = await Assert.ThrowsAsync<InvalidOperationException>(() =>
ConnectRetryExecutor.ExecuteAsync(
retryOptions: null,
connectAttempt: _ => { attempts++; throw boom; },
connectAttempt: (_, _) => { attempts++; throw boom; },
onAttemptFailed: () => failedCleanups++,
onStatusChanged: (_, _) => { }));

Expand All @@ -84,7 +84,7 @@ public async Task ExecuteAsync_RetriesUntilSuccess_CleansUpEachFailedAttempt()

await ConnectRetryExecutor.ExecuteAsync(
FastRetry(3),
connectAttempt: _ =>
connectAttempt: (_, _) =>
{
attempts++;
if (attempts < 3)
Expand Down Expand Up @@ -120,7 +120,7 @@ public async Task ExecuteAsync_AllAttemptsFail_ThrowsLastExceptionAndReportsItUn
var thrown = await Assert.ThrowsAsync<InvalidOperationException>(() =>
ConnectRetryExecutor.ExecuteAsync(
FastRetry(3),
connectAttempt: _ =>
connectAttempt: (_, _) =>
{
attempts++;
throw attempts < 3 ? new InvalidOperationException($"fail {attempts}") : lastBoom;
Expand Down Expand Up @@ -155,7 +155,7 @@ public async Task ExecuteAsync_RetryDisabledWithMultipleMaxAttempts_TriesOnce()
await Assert.ThrowsAsync<InvalidOperationException>(() =>
ConnectRetryExecutor.ExecuteAsync(
options,
connectAttempt: _ => { attempts++; throw new InvalidOperationException("boom"); },
connectAttempt: (_, _) => { attempts++; throw new InvalidOperationException("boom"); },
onAttemptFailed: () => { },
onStatusChanged: (_, _) => { }));

Expand All @@ -179,7 +179,7 @@ public async Task ExecuteAsync_AppliesBackoffDelayBetweenAttempts()

await ConnectRetryExecutor.ExecuteAsync(
options,
connectAttempt: _ =>
connectAttempt: (_, _) =>
{
attempts++;
if (attempts == 1)
Expand All @@ -198,4 +198,115 @@ await ConnectRetryExecutor.ExecuteAsync(
Assert.True(sw.ElapsedMilliseconds >= 80,
$"Expected a backoff delay before retry, but only {sw.ElapsedMilliseconds}ms elapsed.");
}

[Fact]
public async Task ExecuteAsync_TokenAlreadyCanceled_NeverAttempts()
{
var attempts = 0;
using var cts = new CancellationTokenSource();
await cts.CancelAsync();

await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
ConnectRetryExecutor.ExecuteAsync(
FastRetry(3),
connectAttempt: (_, _) => { attempts++; return Task.CompletedTask; },
onAttemptFailed: () => { },
onStatusChanged: (_, _) => { },
cancellationToken: cts.Token));

Assert.Equal(0, attempts);
}

[Fact]
public async Task ExecuteAsync_CanceledAttempt_IsNotRetried()
{
// A cancelled attempt is the caller walking away, not a transient failure — the loop must
// stop rather than burn through the remaining attempts. This is what lets an auto-reconnect
// loop (issue #379) be torn down promptly.
var attempts = 0;
var failedCleanups = 0;
var statuses = new List<(bool Connected, Exception? Error)>();
using var cts = new CancellationTokenSource();

await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
ConnectRetryExecutor.ExecuteAsync(
FastRetry(5),
connectAttempt: (_, token) =>
{
attempts++;
cts.Cancel();
token.ThrowIfCancellationRequested();
return Task.CompletedTask;
},
onAttemptFailed: () => failedCleanups++,
onStatusChanged: (c, e) => statuses.Add((c, e)),
cancellationToken: cts.Token));

Assert.Equal(1, attempts);
// The half-built handle is still cleaned up, and the transport is reported disconnected.
Assert.Equal(1, failedCleanups);
var status = Assert.Single(statuses);
Assert.False(status.Connected);
Assert.IsAssignableFrom<OperationCanceledException>(status.Error);
}

[Fact]
public async Task ExecuteAsync_CanceledWhileAnAttemptFailedNormally_DoesNotDialAgain()
{
// Zero backoff means there is no Task.Delay to notice the cancellation, so the per-iteration
// check is the only thing standing between a cancelled caller and another connection attempt.
var attempts = 0;
using var cts = new CancellationTokenSource();

await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
ConnectRetryExecutor.ExecuteAsync(
FastRetry(5),
connectAttempt: (_, _) =>
{
attempts++;
cts.Cancel();
throw new InvalidOperationException("boom");
},
onAttemptFailed: () => { },
onStatusChanged: (_, _) => { },
cancellationToken: cts.Token));

Assert.Equal(1, attempts);
}

[Fact]
public async Task ExecuteAsync_CanceledDuringBackoffDelay_StopsWaiting()
{
var options = new ConnectionRetryOptions
{
Enabled = true,
MaxAttempts = 3,
InitialDelay = TimeSpan.FromSeconds(30),
MaxDelay = TimeSpan.FromSeconds(30),
BackoffMultiplier = 1.0
};

var attempts = 0;
using var cts = new CancellationTokenSource();
var sw = Stopwatch.StartNew();

var run = ConnectRetryExecutor.ExecuteAsync(
options,
connectAttempt: (_, _) =>
{
attempts++;
throw new InvalidOperationException("boom");
},
onAttemptFailed: () => { },
onStatusChanged: (_, _) => { },
cancellationToken: cts.Token);

cts.CancelAfter(TimeSpan.FromMilliseconds(100));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => run);
sw.Stop();

Assert.Equal(1, attempts);
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10),
$"The 30s backoff delay was not cancellable ({sw.ElapsedMilliseconds}ms elapsed).");
}
}
Loading