diff --git a/docs/DEVICE_INTERFACES.md b/docs/DEVICE_INTERFACES.md
index 2458a8f9..937488b4 100644
--- a/docs/DEVICE_INTERFACES.md
+++ b/docs/DEVICE_INTERFACES.md
@@ -243,6 +243,93 @@ you need a different interval per transport.) Devices are deduplicated per trans
physical unit seen over both WiFi and Serial appears as two distinct connection options.
`continuous.Devices` returns a thread-safe snapshot of the current set at any time.
+### Managing Multiple Devices (`DaqifiDeviceRegistry`)
+
+Multi-device is the normal DAQ case, and `DaqifiDeviceFactory` hands back one device at a time
+without tracking the live set. `DaqifiDeviceRegistry` is that missing layer: a thread-safe set of
+connected devices that owns their lifetime, raises add/remove events, and — the part every
+consumer otherwise reimplements — recognizes **the same physical unit reached over two transports
+at once**, the classic "already connected via USB, now discovered over WiFi" support trap.
+
+```csharp
+using Daqifi.Core.Device;
+
+using var registry = new DaqifiDeviceRegistry();
+
+registry.DeviceAdded += (_, e) => devices.Add(e.Registration); // bind to your UI list
+registry.DeviceRemoved += (_, e) => devices.Remove(e.Registration); // e.Reason says why
+
+foreach (var info in await finder.DiscoverAsync(TimeSpan.FromSeconds(3)))
+{
+ var result = await registry.ConnectAsync(info);
+
+ // Not an error: the unit was already connected over another transport, and the registry
+ // handed back that live connection instead of opening a redundant second one.
+ if (result.Outcome == DeviceRegistrationOutcome.DuplicateRejected)
+ {
+ logger.LogInformation("{Name} is already connected as {Key}", info.Name, result.Key);
+ }
+}
+
+// Later: the registry disconnects and disposes whatever it removes.
+registry.Remove(key);
+```
+
+**Keys vs. identity.** Two separate concepts run through the API:
+
+| | What it is | Used for |
+|---|---|---|
+| **Key** (`DeviceRegistration.Key`) | The handle you look a device up by. Defaults to `DeviceIdentity.Key`; pass your own to `ConnectAsync`/`Register` if you already mint device ids. | `TryGet`, `Remove` |
+| **Identity** (`DeviceIdentity`) | The fingerprint of the physical unit: serial number → MAC address → USB `LocationKey`. | Duplicate detection |
+
+Identity matching walks those three discriminators and is decided by the first one **both** sides
+report: serial numbers compare case-insensitively and decisively (different serials are different
+units, full stop), MAC comparison ignores separators, and the USB location key is the last resort
+for identical units that report no serial. A device that reports none of the three never matches
+another — two unidentifiable devices are treated as two devices, not one.
+
+**Duplicate policy.** The check runs twice: once from `IDeviceInfo` before connecting (free to
+reject — nothing is open yet) and again from `DaqifiDevice.Metadata` afterwards, because a
+serial-port device's serial number is often only known once it has answered its first status
+message. With no callback set the existing connection wins and the new one is rejected. To decide
+yourself — for example by prompting the user:
+
+```csharp
+registry.DuplicatePolicy = check =>
+{
+ var message = $"{check.Existing.Device.Name} is already connected via " +
+ $"{check.ExistingConnectionType}. Switch to {check.NewConnectionType}?";
+
+ return PromptUser(message)
+ ? DuplicateDeviceAction.SwitchToNew // drop the existing connection, keep the new one
+ : DuplicateDeviceAction.KeepExisting; // or Cancel to abandon the attempt entirely
+};
+```
+
+`SwitchToNew` opens the replacement *before* dropping the existing connection, so switching to a
+transport that turns out not to answer leaves the original in place rather than costing you both.
+The trade-off is that switching to a connection needing the same resource the old one holds (the
+same serial port) fails instead of freeing it first — for that, `Remove(key)` then `ConnectAsync`.
+The policy is asked once per connection attempt, not again after connecting.
+
+**Ownership and liveness.** The registry disconnects and disposes every device it removes,
+including duplicates it rejects — once a device is passed to `ConnectAsync` or `Register`, don't
+dispose it yourself. Registrations whose device stops reporting `IsConnected` are pruned before
+every registration attempt (and by `PruneDisconnected()` on demand). The registry does not
+reconnect on its own.
+
+> **Know this limit:** pruning is only as good as the transport's drop detection.
+> `SerialStreamTransport.IsConnected` reports the OS handle's state, which stays open after a USB
+> device is physically unplugged, so that device keeps reporting `IsConnected` and is *not* pruned —
+> a later `ConnectAsync` under the same key hands back the dead handle rather than reconnecting.
+> Devices you `Disconnect()` yourself, and drops a transport does report, prune as described.
+
+All members are safe to call from any thread; reads return snapshots, and the duplicate policy is
+always invoked without the internal lock held, so a policy that blocks on a user prompt never
+blocks other threads. Two concurrent `ConnectAsync` calls for the *same* physical device may both
+open a connection — the loser is detected after connecting and disposed — so serialize your own
+calls if a single connect attempt matters.
+
### Manual Device Connection (Advanced)
For cases where you need more control over the connection process:
@@ -576,6 +663,8 @@ However, for connection state changes (Connect/Disconnect), coordinate access fr
## Features
- **Simple Factory API**: Single-call connection with `DaqifiDeviceFactory`
+- **Multi-Device Registry**: `DaqifiDeviceRegistry` tracks the live device set and detects the same
+ unit connected over two transports
- **Clean Abstraction**: Hardware details hidden behind well-defined interfaces
- **Event-Driven**: Status changes and messages handled via events
- **Type Safety**: Generic message types provide compile-time safety
diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceRegistryTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceRegistryTests.cs
new file mode 100644
index 00000000..e9be4d52
--- /dev/null
+++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceRegistryTests.cs
@@ -0,0 +1,898 @@
+using Daqifi.Core.Device;
+using Daqifi.Core.Device.Discovery;
+
+namespace Daqifi.Core.Tests.Device;
+
+///
+/// Registry behavior, including the cross-transport duplicate-detection cases ported from the
+/// desktop app's DuplicateDeviceDetectionTests (the reference implementation this type
+/// replaces).
+///
+public class DaqifiDeviceRegistryTests
+{
+ #region Helpers
+
+ ///
+ /// A device that reports itself connected without touching any transport — the no-transport
+ /// constructor short-circuits to a status change.
+ ///
+ private static DaqifiDevice ConnectedDevice(string name = "device", string? serialNumber = null, string? macAddress = null)
+ {
+ var device = new DaqifiDevice(name);
+ if (serialNumber != null)
+ {
+ device.Metadata.SerialNumber = serialNumber;
+ }
+ if (macAddress != null)
+ {
+ device.Metadata.MacAddress = macAddress;
+ }
+
+ device.Connect();
+ return device;
+ }
+
+ private static DeviceInfo UsbInfo(string serialNumber = "", string portName = "COM3", string? locationKey = null) => new()
+ {
+ Name = "usb-device",
+ ConnectionType = ConnectionType.Serial,
+ SerialNumber = serialNumber,
+ PortName = portName,
+ LocationKey = locationKey
+ };
+
+ private static DeviceInfo WifiInfo(string serialNumber = "", string? macAddress = null) => new()
+ {
+ Name = "wifi-device",
+ ConnectionType = ConnectionType.WiFi,
+ SerialNumber = serialNumber,
+ MacAddress = macAddress,
+ IPAddress = System.Net.IPAddress.Parse("192.168.1.50"),
+ Port = 9760
+ };
+
+ ///
+ /// Builds a registry whose connect step is a stub, so the duplicate-policy paths can be
+ /// exercised without hardware. The stub hands out the queued devices in order.
+ ///
+ private static DaqifiDeviceRegistry RegistryReturning(List connectCalls, params DaqifiDevice[] devices)
+ {
+ var index = 0;
+ return new DaqifiDeviceRegistry((_, _, _) =>
+ {
+ var next = devices[index++];
+ connectCalls.Add(index);
+ return Task.FromResult(next);
+ });
+ }
+
+ #endregion
+
+ #region Duplicate detection (ported from desktop's DuplicateDeviceDetectionTests)
+
+ [Fact]
+ public async Task ConnectAsync_DuplicateSerialNumber_KeepsExistingAndRejectsNew()
+ {
+ const string serialNumber = "DAQ-12345";
+ var connects = new List();
+ var overUsb = ConnectedDevice("usb", serialNumber);
+ var overWifi = ConnectedDevice("wifi", serialNumber);
+ using var registry = RegistryReturning(connects, overUsb, overWifi);
+
+ await registry.ConnectAsync(UsbInfo(serialNumber));
+ var result = await registry.ConnectAsync(WifiInfo(serialNumber));
+
+ Assert.Equal(DeviceRegistrationOutcome.DuplicateRejected, result.Outcome);
+ Assert.True(result.WasDuplicate);
+ Assert.False(result.IsRegistered);
+ Assert.Same(overUsb, result.Device);
+ Assert.Equal(1, registry.Count);
+ // The duplicate was caught from discovery metadata, so no second connection was opened.
+ Assert.Single(connects);
+ Assert.True(overUsb.IsConnected);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_DifferentSerialNumbers_RegistersBoth()
+ {
+ var connects = new List();
+ using var registry = RegistryReturning(
+ connects,
+ ConnectedDevice("usb", "DAQ-12345"),
+ ConnectedDevice("wifi", "DAQ-67890"));
+
+ var first = await registry.ConnectAsync(UsbInfo("DAQ-12345"));
+ var second = await registry.ConnectAsync(WifiInfo("DAQ-67890"));
+
+ Assert.Equal(DeviceRegistrationOutcome.Registered, first.Outcome);
+ Assert.Equal(DeviceRegistrationOutcome.Registered, second.Outcome);
+ Assert.Equal(2, registry.Count);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_DevicesWithoutIdentity_NeverMatchEachOther()
+ {
+ var connects = new List();
+ using var registry = RegistryReturning(connects, ConnectedDevice("first"), ConnectedDevice("second"));
+
+ var first = await registry.ConnectAsync(UsbInfo(serialNumber: "", portName: "COM3"));
+ var second = await registry.ConnectAsync(UsbInfo(serialNumber: "", portName: "COM4"));
+
+ Assert.Equal(DeviceRegistrationOutcome.Registered, first.Outcome);
+ Assert.Equal(DeviceRegistrationOutcome.Registered, second.Outcome);
+ Assert.Equal(2, registry.Count);
+ Assert.NotEqual(first.Key, second.Key);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_DuplicatePolicy_IsInvokedWithBothTransports()
+ {
+ const string serialNumber = "DAQ-12345";
+ var connects = new List();
+ var checks = new List();
+ using var registry = RegistryReturning(
+ connects,
+ ConnectedDevice("usb", serialNumber),
+ ConnectedDevice("wifi", serialNumber));
+ registry.DuplicatePolicy = check =>
+ {
+ checks.Add(check);
+ return DuplicateDeviceAction.KeepExisting;
+ };
+
+ await registry.ConnectAsync(UsbInfo(serialNumber));
+ await registry.ConnectAsync(WifiInfo(serialNumber));
+
+ var check = Assert.Single(checks);
+ Assert.Equal(DuplicateCheckPhase.BeforeConnect, check.Phase);
+ Assert.Equal(ConnectionType.Serial, check.ExistingConnectionType);
+ Assert.Equal(ConnectionType.WiFi, check.NewConnectionType);
+ Assert.Null(check.NewDevice);
+ Assert.Equal(serialNumber, check.NewIdentity.SerialNumber);
+ Assert.Equal(1, registry.Count);
+ }
+
+ #endregion
+
+ #region Duplicate detection: identity fallbacks and phases
+
+ [Fact]
+ public async Task ConnectAsync_SerialMatch_IsCaseInsensitive()
+ {
+ var connects = new List();
+ using var registry = RegistryReturning(
+ connects,
+ ConnectedDevice("usb", "DAQ-12345"),
+ ConnectedDevice("wifi", "daq-12345"));
+
+ await registry.ConnectAsync(UsbInfo("DAQ-12345"));
+ var result = await registry.ConnectAsync(WifiInfo("daq-12345"));
+
+ Assert.Equal(DeviceRegistrationOutcome.DuplicateRejected, result.Outcome);
+ Assert.Equal(1, registry.Count);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_MatchesOnMac_WhenSerialIsBlank()
+ {
+ var connects = new List();
+ using var registry = RegistryReturning(
+ connects,
+ ConnectedDevice("first", macAddress: "AA-BB-CC-DD-EE-FF"),
+ ConnectedDevice("second", macAddress: "aa:bb:cc:dd:ee:ff"));
+
+ await registry.ConnectAsync(WifiInfo(macAddress: "AA-BB-CC-DD-EE-FF"));
+ var result = await registry.ConnectAsync(WifiInfo(macAddress: "aa:bb:cc:dd:ee:ff"));
+
+ Assert.Equal(DeviceRegistrationOutcome.DuplicateRejected, result.Outcome);
+ Assert.Equal(1, registry.Count);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_MatchesOnLocationKey_WhenSerialAndMacAreBlank()
+ {
+ var connects = new List();
+ using var registry = RegistryReturning(connects, ConnectedDevice("first"), ConnectedDevice("second"));
+
+ await registry.ConnectAsync(UsbInfo(portName: "COM3", locationKey: "Port_#0001.Hub_#0001"));
+ var result = await registry.ConnectAsync(UsbInfo(portName: "COM7", locationKey: "Port_#0001.Hub_#0001"));
+
+ Assert.Equal(DeviceRegistrationOutcome.DuplicateRejected, result.Outcome);
+ Assert.Equal(1, registry.Count);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_SerialOnlyKnownAfterConnect_IsCaughtByThePostConnectCheck()
+ {
+ const string serialNumber = "DAQ-12345";
+ var connects = new List();
+ var phases = new List();
+ var overWifi = ConnectedDevice("wifi", serialNumber);
+ var overUsb = ConnectedDevice("usb", serialNumber);
+ using var registry = RegistryReturning(connects, overUsb);
+ registry.Register(overWifi, WifiInfo(serialNumber));
+ registry.DuplicatePolicy = check =>
+ {
+ phases.Add(check.Phase);
+ return DuplicateDeviceAction.KeepExisting;
+ };
+
+ // Serial-port discovery could not read a serial number, so the duplicate only becomes
+ // visible once the device has answered its first status message.
+ var result = await registry.ConnectAsync(UsbInfo(serialNumber: "", portName: "COM3"));
+
+ Assert.Equal(DeviceRegistrationOutcome.DuplicateRejected, result.Outcome);
+ Assert.Same(overWifi, result.Device);
+ Assert.Equal(1, registry.Count);
+ Assert.Single(connects);
+ Assert.Equal(DuplicateCheckPhase.AfterConnect, Assert.Single(phases));
+ // The connection opened only to discover it was redundant is disposed by the registry.
+ Assert.False(overUsb.IsConnected);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_PostConnectCheck_ReportsTheConnectedDeviceToThePolicy()
+ {
+ const string serialNumber = "DAQ-12345";
+ var connects = new List();
+ var newDevice = ConnectedDevice("usb", serialNumber);
+ using var registry = RegistryReturning(connects, newDevice);
+ registry.Register(ConnectedDevice("wifi", serialNumber), WifiInfo(serialNumber));
+ DuplicateDeviceCheck? seen = null;
+ registry.DuplicatePolicy = check =>
+ {
+ seen = check;
+ return DuplicateDeviceAction.KeepExisting;
+ };
+
+ await registry.ConnectAsync(UsbInfo(serialNumber: "", portName: "COM3"));
+
+ Assert.NotNull(seen);
+ Assert.Same(newDevice, seen!.NewDevice);
+ Assert.Equal(ConnectionType.Serial, seen.NewConnectionType);
+ Assert.Equal(serialNumber, seen.NewIdentity.SerialNumber);
+ }
+
+ #endregion
+
+ #region Duplicate policy actions
+
+ [Fact]
+ public async Task ConnectAsync_SwitchToNew_ReplacesTheExistingConnection()
+ {
+ const string serialNumber = "DAQ-12345";
+ var connects = new List();
+ var overUsb = ConnectedDevice("usb", serialNumber);
+ var overWifi = ConnectedDevice("wifi", serialNumber);
+ var removals = new List();
+ using var registry = RegistryReturning(connects, overUsb, overWifi);
+ registry.DeviceRemoved += (_, e) => removals.Add(e);
+ registry.DuplicatePolicy = _ => DuplicateDeviceAction.SwitchToNew;
+
+ await registry.ConnectAsync(UsbInfo(serialNumber));
+ var result = await registry.ConnectAsync(WifiInfo(serialNumber));
+
+ Assert.Equal(DeviceRegistrationOutcome.ReplacedExisting, result.Outcome);
+ Assert.True(result.IsRegistered);
+ Assert.Same(overWifi, result.Device);
+ Assert.Equal(1, registry.Count);
+ Assert.False(overUsb.IsConnected);
+
+ var removal = Assert.Single(removals);
+ Assert.Equal(DeviceRemovalReason.Replaced, removal.Reason);
+ Assert.Same(overUsb, removal.Registration.Device);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_SwitchToNew_WhenTheNewConnectionFails_KeepsTheExistingOne()
+ {
+ const string serialNumber = "DAQ-12345";
+ var overUsb = ConnectedDevice("usb", serialNumber);
+ var attempt = 0;
+ using var registry = new DaqifiDeviceRegistry((_, _, _) =>
+ {
+ attempt++;
+ return attempt == 1
+ ? Task.FromResult(overUsb)
+ : Task.FromException(
+ new TimeoutException("TCP connect to 192.0.2.1:9760 timed out after 5s."));
+ });
+ var removals = new List();
+ registry.DeviceRemoved += (_, e) => removals.Add(e);
+
+ await registry.ConnectAsync(UsbInfo(serialNumber));
+ registry.DuplicatePolicy = _ => DuplicateDeviceAction.SwitchToNew;
+
+ // The replacement transport refuses the connection — the real case that caught this: a
+ // device that answers UDP discovery but is not listening on its TCP data port. Switching
+ // to it must not cost the caller the healthy USB session they already had.
+ await Assert.ThrowsAsync(() => registry.ConnectAsync(WifiInfo(serialNumber)));
+
+ Assert.Equal(1, registry.Count);
+ Assert.True(overUsb.IsConnected);
+ Assert.Same(overUsb, Assert.Single(registry.Devices).Device);
+ Assert.Empty(removals);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_SwitchToNew_DropsTheOldConnectionOnlyAfterTheNewOneIsOpen()
+ {
+ const string serialNumber = "DAQ-12345";
+ var overUsb = ConnectedDevice("usb", serialNumber);
+
+ // Deliberately left disconnected: the replacement opens inside the connector below and
+ // nowhere earlier, which is what makes the ordering assertion meaningful. Connecting it
+ // during setup (as the ConnectedDevice helper does) would make the assertion tautological —
+ // true whether or not the registry actually waits.
+ var overWifi = new DaqifiDevice("wifi");
+ overWifi.Metadata.SerialNumber = serialNumber;
+
+ var policyCalls = new List();
+ var replacementWasOpenWhenOldWasDropped = false;
+ var connectorRuns = 0;
+
+ using var registry = new DaqifiDeviceRegistry((_, _, _) =>
+ {
+ if (connectorRuns++ == 0)
+ {
+ return Task.FromResult(overUsb);
+ }
+
+ overWifi.Connect();
+ return Task.FromResult(overWifi);
+ });
+
+ await registry.ConnectAsync(UsbInfo(serialNumber));
+
+ registry.DuplicatePolicy = check =>
+ {
+ policyCalls.Add(check.Phase);
+ return DuplicateDeviceAction.SwitchToNew;
+ };
+ registry.DeviceRemoved += (_, _) =>
+ {
+ // At the moment the old registration is dropped, the replacement must already be live.
+ // Drop-the-old-first ordering would observe it still disconnected here.
+ replacementWasOpenWhenOldWasDropped = overWifi.IsConnected;
+ };
+
+ var result = await registry.ConnectAsync(WifiInfo(serialNumber));
+
+ Assert.Equal(DeviceRegistrationOutcome.ReplacedExisting, result.Outcome);
+ Assert.True(replacementWasOpenWhenOldWasDropped);
+ Assert.Equal(2, connectorRuns);
+ Assert.False(overUsb.IsConnected);
+ Assert.Equal(1, registry.Count);
+ Assert.Same(overWifi, Assert.Single(registry.Devices).Device);
+ // Asked once, pre-connect — a consumer prompting its user must not see two dialogs.
+ Assert.Equal(DuplicateCheckPhase.BeforeConnect, Assert.Single(policyCalls));
+ }
+
+ [Fact]
+ public async Task ConnectAsync_Cancel_LeavesTheExistingConnectionAndRegistersNothing()
+ {
+ const string serialNumber = "DAQ-12345";
+ var connects = new List();
+ var overUsb = ConnectedDevice("usb", serialNumber);
+ using var registry = RegistryReturning(connects, overUsb, ConnectedDevice("wifi", serialNumber));
+ registry.DuplicatePolicy = _ => DuplicateDeviceAction.Cancel;
+
+ await registry.ConnectAsync(UsbInfo(serialNumber));
+ var result = await registry.ConnectAsync(WifiInfo(serialNumber));
+
+ Assert.Equal(DeviceRegistrationOutcome.Canceled, result.Outcome);
+ Assert.Null(result.Registration);
+ Assert.Null(result.Device);
+ Assert.Null(result.Key);
+ Assert.Equal(1, registry.Count);
+ Assert.True(overUsb.IsConnected);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_PolicyRemovesTheExistingRegistrationWhileDeciding_NeverReturnsTheDeadHandle()
+ {
+ const string serialNumber = "DAQ-12345";
+ var connects = new List();
+ var overUsb = ConnectedDevice("usb", serialNumber);
+ var overWifi = ConnectedDevice("wifi", serialNumber);
+ using var registry = RegistryReturning(connects, overUsb, overWifi);
+
+ await registry.ConnectAsync(UsbInfo(serialNumber));
+
+ // The policy runs without the registry lock and may block on a user prompt, so the live
+ // set can change underneath it — here the existing connection is torn down while the
+ // prompt is up. KeepExisting must not then hand back the registration that just died.
+ registry.DuplicatePolicy = check =>
+ {
+ registry.Remove(check.Existing.Key);
+ return DuplicateDeviceAction.KeepExisting;
+ };
+
+ var result = await registry.ConnectAsync(WifiInfo(serialNumber));
+
+ Assert.Equal(DeviceRegistrationOutcome.Registered, result.Outcome);
+ Assert.Same(overWifi, result.Device);
+ Assert.True(result.Device!.IsConnected);
+ Assert.Equal(1, registry.Count);
+ Assert.False(overUsb.IsConnected);
+ }
+
+ [Fact]
+ public void Register_PolicyRemovesTheExistingRegistrationWhileDeciding_RegistersTheNewDevice()
+ {
+ const string serialNumber = "DAQ-12345";
+ using var registry = new DaqifiDeviceRegistry();
+ var existing = ConnectedDevice("usb", serialNumber);
+ var replacement = ConnectedDevice("wifi", serialNumber);
+ registry.Register(existing);
+
+ // Same race on the post-connect path, where the device has already been connected: it must
+ // be registered rather than disposed in favor of a registration that no longer exists.
+ registry.DuplicatePolicy = check =>
+ {
+ registry.Remove(check.Existing.Key);
+ return DuplicateDeviceAction.KeepExisting;
+ };
+
+ var result = registry.Register(replacement);
+
+ Assert.Equal(DeviceRegistrationOutcome.Registered, result.Outcome);
+ Assert.Same(replacement, result.Device);
+ Assert.True(replacement.IsConnected);
+ Assert.Equal(1, registry.Count);
+ }
+
+ [Fact]
+ public void Register_PolicyReturnsUndefinedAction_FallsBackToKeepingTheExistingDevice()
+ {
+ const string serialNumber = "DAQ-12345";
+ using var registry = new DaqifiDeviceRegistry();
+ var existing = ConnectedDevice("usb", serialNumber);
+ var duplicate = ConnectedDevice("wifi", serialNumber);
+ registry.Register(existing);
+ registry.DuplicatePolicy = _ => (DuplicateDeviceAction)42;
+
+ var result = registry.Register(duplicate);
+
+ Assert.Equal(DeviceRegistrationOutcome.DuplicateRejected, result.Outcome);
+ Assert.Same(existing, result.Device);
+ Assert.False(duplicate.IsConnected);
+ }
+
+ [Fact]
+ public void Register_PolicyThrows_PropagatesAndStillDisposesTheDevice()
+ {
+ const string serialNumber = "DAQ-12345";
+ using var registry = new DaqifiDeviceRegistry();
+ registry.Register(ConnectedDevice("usb", serialNumber));
+ var duplicate = ConnectedDevice("wifi", serialNumber);
+ registry.DuplicatePolicy = _ => throw new InvalidOperationException("policy failed");
+
+ Assert.Throws(() => registry.Register(duplicate));
+
+ Assert.Equal(1, registry.Count);
+ Assert.False(duplicate.IsConnected);
+ }
+
+ #endregion
+
+ #region Registration and keys
+
+ [Fact]
+ public void Register_ConnectedDevice_IsFiledUnderItsIdentityKey()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ var device = ConnectedDevice("usb", "DAQ-12345");
+
+ var result = registry.Register(device, UsbInfo("DAQ-12345"));
+
+ Assert.Equal(DeviceRegistrationOutcome.Registered, result.Outcome);
+ Assert.Equal("sn:daq-12345", result.Key);
+ Assert.True(registry.TryGetDevice("sn:daq-12345", out var found));
+ Assert.Same(device, found);
+ Assert.Equal(ConnectionType.Serial, result.Registration!.ConnectionType);
+ }
+
+ [Fact]
+ public void Register_CallerSuppliedKey_IsUsedForLookups()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ var device = ConnectedDevice("usb", "DAQ-12345");
+
+ var result = registry.Register(device, UsbInfo("DAQ-12345"), key: "serial:COM3");
+
+ Assert.Equal("serial:COM3", result.Key);
+ Assert.True(registry.TryGet("serial:COM3", out var registration));
+ Assert.Same(device, registration!.Device);
+ }
+
+ [Fact]
+ public void Register_SameKeyTwice_IsTreatedAsADuplicate()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ var first = ConnectedDevice("first");
+ var second = ConnectedDevice("second");
+ registry.Register(first, key: "my-device");
+
+ var result = registry.Register(second, key: "my-device");
+
+ Assert.Equal(DeviceRegistrationOutcome.DuplicateRejected, result.Outcome);
+ Assert.Same(first, result.Device);
+ Assert.Equal(1, registry.Count);
+ }
+
+ [Fact]
+ public void Register_DeviceWithoutIdentity_GetsAMintedUniqueKey()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+
+ var first = registry.Register(ConnectedDevice("first"));
+ var second = registry.Register(ConnectedDevice("second"));
+
+ Assert.Equal(2, registry.Count);
+ Assert.NotEqual(first.Key, second.Key);
+ Assert.All(new[] { first.Key, second.Key }, key => Assert.False(string.IsNullOrEmpty(key)));
+ }
+
+ [Fact]
+ public void Register_UsesDiscoveryIdentityWhenMetadataHasNotFilledItInYet()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+
+ var result = registry.Register(ConnectedDevice("usb"), UsbInfo("DAQ-12345", locationKey: "Port_#0001"));
+
+ Assert.Equal("DAQ-12345", result.Registration!.Identity.SerialNumber);
+ Assert.Equal("Port_#0001", result.Registration.Identity.LocationKey);
+ }
+
+ [Fact]
+ public void Register_SameDeviceInstanceTwice_IsANoOpAndKeepsItAlive()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ var device = ConnectedDevice("usb", "DAQ-12345");
+ var first = registry.Register(device);
+
+ var again = registry.Register(device);
+
+ // The device must not be treated as a duplicate of itself: disposing it would leave the
+ // registry holding a dead handle.
+ Assert.Equal(DeviceRegistrationOutcome.Registered, again.Outcome);
+ Assert.Same(first.Registration, again.Registration);
+ Assert.Equal(1, registry.Count);
+ Assert.True(device.IsConnected);
+ }
+
+ [Fact]
+ public void Register_SameDeviceInstanceUnderAnotherKey_ReturnsTheOriginalRegistration()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ var device = ConnectedDevice("anonymous");
+ var first = registry.Register(device, key: "first-key");
+
+ var again = registry.Register(device, key: "second-key");
+
+ Assert.Equal("first-key", again.Key);
+ Assert.Same(first.Registration, again.Registration);
+ Assert.Equal(1, registry.Count);
+ Assert.True(device.IsConnected);
+ }
+
+ [Fact]
+ public void Register_NullDevice_Throws()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+
+ Assert.Throws(() => registry.Register(null!));
+ }
+
+ [Fact]
+ public void Register_DisconnectedDevice_ThrowsWithoutTakingOwnership()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ var device = new DaqifiDevice("never-connected");
+
+ Assert.Throws(() => registry.Register(device));
+ Assert.Equal(0, registry.Count);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_NullDeviceInfo_Throws()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+
+ await Assert.ThrowsAsync(() => registry.ConnectAsync(null!));
+ }
+
+ #endregion
+
+ #region Lookups
+
+ [Fact]
+ public void TryGet_UnknownOrBlankKey_ReturnsFalse()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+
+ Assert.False(registry.TryGet("nope", out var missing));
+ Assert.Null(missing);
+ Assert.False(registry.TryGetDevice("", out var blank));
+ Assert.Null(blank);
+ }
+
+ [Fact]
+ public void Find_MatchesByIdentity_AndIgnoresEmptyIdentities()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ var device = ConnectedDevice("usb", "DAQ-12345");
+ registry.Register(device);
+
+ Assert.Same(device, registry.Find(DeviceIdentity.Create("daq-12345"))!.Device);
+ Assert.Null(registry.Find(DeviceIdentity.Create("DAQ-99999")));
+ Assert.Null(registry.Find(DeviceIdentity.Empty));
+ }
+
+ [Fact]
+ public void Devices_ReturnsAnIndependentSnapshot()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ registry.Register(ConnectedDevice("first", "DAQ-1"));
+
+ var snapshot = registry.Devices;
+ registry.Register(ConnectedDevice("second", "DAQ-2"));
+
+ Assert.Single(snapshot);
+ Assert.Equal(2, registry.Count);
+ }
+
+ #endregion
+
+ #region Removal, pruning, and disposal
+
+ [Fact]
+ public void Remove_ByKey_DisconnectsDisposesAndAnnounces()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ var device = ConnectedDevice("usb", "DAQ-12345");
+ var removals = new List();
+ registry.DeviceRemoved += (_, e) => removals.Add(e);
+ var key = registry.Register(device).Key!;
+
+ Assert.True(registry.Remove(key));
+
+ Assert.Equal(0, registry.Count);
+ Assert.False(device.IsConnected);
+ Assert.Equal(DeviceRemovalReason.Removed, Assert.Single(removals).Reason);
+ Assert.False(registry.Remove(key));
+ }
+
+ [Fact]
+ public void Remove_ByDevice_RemovesOnlyThatInstance()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ var first = ConnectedDevice("first", "DAQ-1");
+ var second = ConnectedDevice("second", "DAQ-2");
+ registry.Register(first);
+ registry.Register(second);
+
+ Assert.True(registry.Remove(first));
+
+ Assert.Equal(1, registry.Count);
+ Assert.False(first.IsConnected);
+ Assert.True(second.IsConnected);
+ Assert.False(registry.Remove(ConnectedDevice("unregistered")));
+ }
+
+ [Fact]
+ public void PruneDisconnected_DropsStaleRegistrations()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ var dropped = ConnectedDevice("dropped", "DAQ-1");
+ var live = ConnectedDevice("live", "DAQ-2");
+ registry.Register(dropped);
+ registry.Register(live);
+ var removals = new List();
+ registry.DeviceRemoved += (_, e) => removals.Add(e);
+
+ // The device dropped off the bus behind the registry's back.
+ dropped.Disconnect();
+
+ Assert.Equal(1, registry.PruneDisconnected());
+ Assert.Equal(1, registry.Count);
+ Assert.Equal(DeviceRemovalReason.Disconnected, Assert.Single(removals).Reason);
+ Assert.Equal(0, registry.PruneDisconnected());
+ }
+
+ [Fact]
+ public async Task ConnectAsync_StaleRegistrationUnderTheSameKey_IsPrunedAndReconnected()
+ {
+ var connects = new List();
+ var dropped = ConnectedDevice("dropped", "DAQ-12345");
+ var reconnected = ConnectedDevice("reconnected", "DAQ-12345");
+ using var registry = RegistryReturning(connects, dropped, reconnected);
+
+ await registry.ConnectAsync(UsbInfo("DAQ-12345"), key: "serial:COM3");
+ dropped.Disconnect();
+ var result = await registry.ConnectAsync(UsbInfo("DAQ-12345"), key: "serial:COM3");
+
+ Assert.Equal(DeviceRegistrationOutcome.Registered, result.Outcome);
+ Assert.Same(reconnected, result.Device);
+ Assert.Equal(1, registry.Count);
+ }
+
+ [Fact]
+ public void Clear_RemovesAndDisposesEverything()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ var first = ConnectedDevice("first", "DAQ-1");
+ var second = ConnectedDevice("second", "DAQ-2");
+ registry.Register(first);
+ registry.Register(second);
+ var removals = new List();
+ registry.DeviceRemoved += (_, e) => removals.Add(e);
+
+ registry.Clear();
+
+ Assert.Equal(0, registry.Count);
+ Assert.False(first.IsConnected);
+ Assert.False(second.IsConnected);
+ Assert.Equal(2, removals.Count);
+ Assert.All(removals, r => Assert.Equal(DeviceRemovalReason.Cleared, r.Reason));
+ }
+
+ [Fact]
+ public void Dispose_DisposesDevicesWithoutAnnouncingRemovals()
+ {
+ var registry = new DaqifiDeviceRegistry();
+ var device = ConnectedDevice("usb", "DAQ-12345");
+ registry.Register(device);
+ var removals = 0;
+ registry.DeviceRemoved += (_, _) => removals++;
+
+ registry.Dispose();
+ registry.Dispose();
+
+ Assert.False(device.IsConnected);
+ Assert.Equal(0, removals);
+ Assert.Equal(0, registry.Count);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_RegistryDisposedWhileThePolicyDecides_NeverReachesTheConnector()
+ {
+ const string serialNumber = "DAQ-12345";
+ var connectorRuns = 0;
+ using var registry = new DaqifiDeviceRegistry((_, _, _) =>
+ {
+ connectorRuns++;
+ return Task.FromResult(ConnectedDevice($"device-{connectorRuns}", serialNumber));
+ });
+
+ await registry.ConnectAsync(UsbInfo(serialNumber));
+ Assert.Equal(1, connectorRuns);
+
+ // The policy may block indefinitely on a user prompt, and the app can shut the registry
+ // down while that prompt is up. No new transport activity may start on the way down.
+ registry.DuplicatePolicy = _ =>
+ {
+ registry.Dispose();
+ return DuplicateDeviceAction.SwitchToNew;
+ };
+
+ await Assert.ThrowsAsync(
+ () => registry.ConnectAsync(WifiInfo(serialNumber)));
+
+ Assert.Equal(1, connectorRuns);
+ }
+
+ [Fact]
+ public async Task ConnectAsync_CancelledWhileThePolicyDecides_NeverReachesTheConnector()
+ {
+ const string serialNumber = "DAQ-12345";
+ var connectorRuns = 0;
+ using var registry = new DaqifiDeviceRegistry((_, _, _) =>
+ {
+ connectorRuns++;
+ return Task.FromResult(ConnectedDevice($"device-{connectorRuns}", serialNumber));
+ });
+ using var cancellation = new CancellationTokenSource();
+
+ await registry.ConnectAsync(UsbInfo(serialNumber), cancellationToken: cancellation.Token);
+ Assert.Equal(1, connectorRuns);
+
+ // Cancellation requested while the policy blocks must be observed before connecting, not
+ // left to the connector — this stub ignores the token, as a custom connector may.
+ registry.DuplicatePolicy = _ =>
+ {
+ cancellation.Cancel();
+ return DuplicateDeviceAction.SwitchToNew;
+ };
+
+ await Assert.ThrowsAnyAsync(
+ () => registry.ConnectAsync(WifiInfo(serialNumber), cancellationToken: cancellation.Token));
+
+ Assert.Equal(1, connectorRuns);
+ // The existing registration is untouched: a cancelled switch costs nothing.
+ Assert.Equal(1, registry.Count);
+ }
+
+ [Fact]
+ public async Task DisposedRegistry_RejectsFurtherRegistrations()
+ {
+ var registry = new DaqifiDeviceRegistry();
+ registry.Dispose();
+
+ Assert.Throws(() => registry.Register(ConnectedDevice("usb", "DAQ-1")));
+ await Assert.ThrowsAsync(() => registry.ConnectAsync(UsbInfo("DAQ-1")));
+ }
+
+ #endregion
+
+ #region Events
+
+ [Fact]
+ public void DeviceAdded_IsRaisedWithTheRegistration()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ var device = ConnectedDevice("usb", "DAQ-12345");
+ var added = new List();
+ registry.DeviceAdded += (_, e) => added.Add(e);
+
+ registry.Register(device, UsbInfo("DAQ-12345"));
+
+ var registration = Assert.Single(added).Registration;
+ Assert.Same(device, registration.Device);
+ Assert.Equal("DAQ-12345", registration.Identity.SerialNumber);
+ }
+
+ [Fact]
+ public void FaultyEventSubscriber_DoesNotFailTheRegistryOperation()
+ {
+ using var registry = new DaqifiDeviceRegistry();
+ registry.DeviceAdded += (_, _) => throw new InvalidOperationException("subscriber blew up");
+ registry.DeviceRemoved += (_, _) => throw new InvalidOperationException("subscriber blew up");
+ var device = ConnectedDevice("usb", "DAQ-12345");
+
+ var key = registry.Register(device).Key!;
+ Assert.Equal(1, registry.Count);
+
+ Assert.True(registry.Remove(key));
+ Assert.Equal(0, registry.Count);
+ }
+
+ #endregion
+
+ #region Concurrency
+
+ [Fact]
+ public async Task Register_IsSafeUnderConcurrency()
+ {
+ const int deviceCount = 50;
+ using var registry = new DaqifiDeviceRegistry();
+ var devices = Enumerable.Range(0, deviceCount)
+ .Select(i => ConnectedDevice($"device-{i}", $"DAQ-{i}"))
+ .ToList();
+
+ await Task.WhenAll(devices.Select(d => Task.Run(() => registry.Register(d))));
+
+ Assert.Equal(deviceCount, registry.Count);
+ Assert.Equal(deviceCount, registry.Devices.Select(r => r.Key).Distinct().Count());
+ }
+
+ [Fact]
+ public async Task Register_ConcurrentDuplicates_LeaveExactlyOneRegistration()
+ {
+ const string serialNumber = "DAQ-12345";
+ using var registry = new DaqifiDeviceRegistry();
+ var devices = Enumerable.Range(0, 20)
+ .Select(i => ConnectedDevice($"device-{i}", serialNumber))
+ .ToList();
+
+ var results = await Task.WhenAll(devices.Select(d => Task.Run(() => registry.Register(d))));
+
+ Assert.Equal(1, registry.Count);
+ Assert.Single(results, r => r.Outcome == DeviceRegistrationOutcome.Registered);
+ Assert.Equal(devices.Count - 1, results.Count(r => r.Outcome == DeviceRegistrationOutcome.DuplicateRejected));
+ Assert.Single(devices, d => d.IsConnected);
+ }
+
+ #endregion
+}
diff --git a/src/Daqifi.Core.Tests/Device/DeviceIdentityTests.cs b/src/Daqifi.Core.Tests/Device/DeviceIdentityTests.cs
new file mode 100644
index 00000000..8e36889a
--- /dev/null
+++ b/src/Daqifi.Core.Tests/Device/DeviceIdentityTests.cs
@@ -0,0 +1,184 @@
+using Daqifi.Core.Device;
+using Daqifi.Core.Device.Discovery;
+
+namespace Daqifi.Core.Tests.Device;
+
+public class DeviceIdentityTests
+{
+ #region Construction
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Create_BlankDiscriminators_AreTreatedAsNotReported(string? blank)
+ {
+ var identity = DeviceIdentity.Create(blank, blank, blank);
+
+ Assert.Null(identity.SerialNumber);
+ Assert.Null(identity.MacAddress);
+ Assert.Null(identity.LocationKey);
+ Assert.True(identity.IsEmpty);
+ Assert.Equal(string.Empty, identity.Key);
+ }
+
+ [Fact]
+ public void Create_TrimsDiscriminators()
+ {
+ var identity = DeviceIdentity.Create(" 1234 ");
+
+ Assert.Equal("1234", identity.SerialNumber);
+ }
+
+ [Fact]
+ public void Key_PrefersSerialThenMacThenLocation()
+ {
+ Assert.Equal("sn:1234", DeviceIdentity.Create("1234", "AA-BB-CC-DD-EE-FF", "Port_#0001").Key);
+ Assert.Equal("mac:aabbccddeeff", DeviceIdentity.Create(null, "AA-BB-CC-DD-EE-FF", "Port_#0001").Key);
+ Assert.Equal("loc:port_#0001", DeviceIdentity.Create(null, null, "Port_#0001").Key);
+ }
+
+ [Fact]
+ public void FromDiscovery_ReadsSerialMacAndLocationKey()
+ {
+ var identity = DeviceIdentity.FromDiscovery(new DeviceInfo
+ {
+ SerialNumber = "1234",
+ MacAddress = "AA-BB-CC-DD-EE-FF",
+ LocationKey = "Port_#0001.Hub_#0001"
+ });
+
+ Assert.Equal("1234", identity.SerialNumber);
+ Assert.Equal("AA-BB-CC-DD-EE-FF", identity.MacAddress);
+ Assert.Equal("Port_#0001.Hub_#0001", identity.LocationKey);
+ }
+
+ [Fact]
+ public void FromMetadata_ReadsSerialAndMac_AndCarriesTheSuppliedLocationKey()
+ {
+ var metadata = new DeviceMetadata { SerialNumber = "1234", MacAddress = "AA-BB-CC-DD-EE-FF" };
+
+ var identity = DeviceIdentity.FromMetadata(metadata, "Port_#0001");
+
+ Assert.Equal("1234", identity.SerialNumber);
+ Assert.Equal("AA-BB-CC-DD-EE-FF", identity.MacAddress);
+ Assert.Equal("Port_#0001", identity.LocationKey);
+ }
+
+ [Fact]
+ public void Factories_NullArgument_Throws()
+ {
+ Assert.Throws(() => DeviceIdentity.FromDiscovery(null!));
+ Assert.Throws(() => DeviceIdentity.FromMetadata(null!));
+ }
+
+ #endregion
+
+ #region Matching
+
+ [Fact]
+ public void Matches_SameSerial_IsCaseInsensitive()
+ {
+ var usb = DeviceIdentity.Create("DAQ-12345");
+ var wifi = DeviceIdentity.Create("daq-12345");
+
+ Assert.True(usb.Matches(wifi));
+ Assert.True(wifi.Matches(usb));
+ }
+
+ [Fact]
+ public void Matches_DifferentSerials_DoNotMatch_EvenWhenAWeakerDiscriminatorAgrees()
+ {
+ // A serial-number mismatch is decisive: two units that both answered with a serial number
+ // are different units, whatever the location key says.
+ var first = DeviceIdentity.Create("1234", locationKey: "Port_#0001");
+ var second = DeviceIdentity.Create("5678", locationKey: "Port_#0001");
+
+ Assert.False(first.Matches(second));
+ }
+
+ [Fact]
+ public void Matches_FallsBackToMac_WhenOneSideHasNoSerial()
+ {
+ var discovered = DeviceIdentity.Create(null, "AA-BB-CC-DD-EE-FF");
+ var connected = DeviceIdentity.Create("1234", "aa:bb:cc:dd:ee:ff");
+
+ Assert.True(discovered.Matches(connected));
+ }
+
+ [Fact]
+ public void Matches_DifferentMacs_DoNotMatch()
+ {
+ var first = DeviceIdentity.Create(null, "AA-BB-CC-DD-EE-FF", "Port_#0001");
+ var second = DeviceIdentity.Create(null, "11-22-33-44-55-66", "Port_#0001");
+
+ Assert.False(first.Matches(second));
+ }
+
+ [Fact]
+ public void Matches_FallsBackToLocationKey_WhenNeitherSideReportsSerialOrMac()
+ {
+ var first = DeviceIdentity.Create(null, null, "PORT_#0001.HUB_#0001");
+ var second = DeviceIdentity.Create(null, null, "port_#0001.hub_#0001");
+
+ Assert.True(first.Matches(second));
+ }
+
+ [Fact]
+ public void Matches_EmptyIdentities_NeverMatch()
+ {
+ Assert.False(DeviceIdentity.Empty.Matches(DeviceIdentity.Empty));
+ Assert.False(DeviceIdentity.Create("1234").Matches(DeviceIdentity.Empty));
+ Assert.False(DeviceIdentity.Empty.Matches(DeviceIdentity.Create("1234")));
+ }
+
+ [Fact]
+ public void Matches_NoSharedDiscriminator_DoesNotMatch()
+ {
+ // One device only reported a MAC, the other only a location key: nothing to compare.
+ var wifi = DeviceIdentity.Create(null, "AA-BB-CC-DD-EE-FF");
+ var usb = DeviceIdentity.Create(null, null, "Port_#0001");
+
+ Assert.False(wifi.Matches(usb));
+ }
+
+ [Fact]
+ public void Matches_Null_DoesNotMatch()
+ {
+ Assert.False(DeviceIdentity.Create("1234").Matches(null));
+ }
+
+ #endregion
+
+ #region Merging
+
+ [Fact]
+ public void MergeWith_FillsOnlyMissingDiscriminators()
+ {
+ var metadata = DeviceIdentity.Create("1234");
+ var discovery = DeviceIdentity.Create("9999", "AA-BB-CC-DD-EE-FF", "Port_#0001");
+
+ var merged = metadata.MergeWith(discovery);
+
+ Assert.Equal("1234", merged.SerialNumber);
+ Assert.Equal("AA-BB-CC-DD-EE-FF", merged.MacAddress);
+ Assert.Equal("Port_#0001", merged.LocationKey);
+ }
+
+ [Fact]
+ public void MergeWith_Null_ReturnsSameIdentity()
+ {
+ var identity = DeviceIdentity.Create("1234");
+
+ Assert.Same(identity, identity.MergeWith(null));
+ }
+
+ #endregion
+
+ [Fact]
+ public void ToString_ListsPopulatedDiscriminators()
+ {
+ Assert.Equal("(unidentified)", DeviceIdentity.Empty.ToString());
+ Assert.Equal("sn=1234, location=Port_#0001", DeviceIdentity.Create("1234", null, "Port_#0001").ToString());
+ }
+}
diff --git a/src/Daqifi.Core/Device/DaqifiDeviceRegistry.cs b/src/Daqifi.Core/Device/DaqifiDeviceRegistry.cs
new file mode 100644
index 00000000..7d1718b4
--- /dev/null
+++ b/src/Daqifi.Core/Device/DaqifiDeviceRegistry.cs
@@ -0,0 +1,889 @@
+using Daqifi.Core.Device.Discovery;
+
+namespace Daqifi.Core.Device;
+
+///
+/// Thread-safe registry of connected DAQiFi devices. Tracks the live device set, recognizes the
+/// same physical unit reached over two transports at once (the classic "already connected via USB,
+/// now discovered over WiFi" case), and owns the lifetime of everything it holds.
+///
+///
+///
+/// Two distinct concepts run through this API:
+///
+///
+/// -
+/// Key — the handle a caller looks a device up by (,
+/// ). It defaults to , but a consumer
+/// that already has its own device ids (an MCP server, a UI list) can supply one. Keys are unique;
+/// registering under a key that is already taken is treated as a duplicate.
+///
+/// -
+/// Identity — the fingerprint of the physical unit, used for
+/// duplicate detection across transports. Two registrations may never share an identity.
+///
+///
+///
+/// Ownership. The registry disconnects and disposes every device it removes — including a
+/// device it rejects as a duplicate and stale registrations it prunes. Once a device is passed to
+/// or , do not dispose it yourself.
+///
+///
+/// Liveness. Registrations whose device stops reporting
+/// are pruned before every registration attempt (and by on demand).
+/// Pruning is only as good as the transport's drop detection, which is a real limit today: the
+/// serial transport reports the OS handle's state, and that stays open after a USB device is
+/// physically unplugged, so such a device keeps reporting IsConnected and is not pruned.
+/// Devices you disconnect yourself, and drops a transport does report, are pruned as described.
+/// The registry does not subscribe to device status events or reconnect on its own; automatic
+/// reconnect is issue #379.
+///
+///
+/// Concurrency. All members are safe to call from any thread. Reads snapshot the live set,
+/// and the duplicate policy is always invoked without the internal lock held, so a policy that
+/// blocks on a user prompt never blocks other threads. Because the set can change while the policy
+/// is deciding, a registration handed back as a duplicate is revalidated afterwards: it is in the
+/// live set at the moment it is returned, and if it went away the check is re-resolved against the
+/// current set instead. Like any handle, it can still be removed by another thread later. Two
+/// concurrent calls for the same physical device may both open
+/// a connection — the loser is detected after connecting and disposed — so a consumer that wants a
+/// single connect attempt should serialize its own calls (see issue #342).
+///
+///
+public sealed class DaqifiDeviceRegistry : IDisposable
+{
+ #region Private Types
+
+ ///
+ /// Opens a connection for . Exists as a seam so the duplicate-policy
+ /// paths can be tested without hardware; production always uses
+ /// .
+ ///
+ internal delegate Task DeviceConnector(
+ IDeviceInfo deviceInfo,
+ DeviceConnectionOptions? options,
+ CancellationToken cancellationToken);
+
+ #endregion
+
+ #region Private Fields
+
+ private readonly Dictionary _devices = new(StringComparer.Ordinal);
+ private readonly object _lock = new();
+ private readonly DeviceConnector _connector;
+
+ private int _mintedKeyCounter;
+ private volatile bool _disposed;
+
+ #endregion
+
+ #region Constructors
+
+ ///
+ /// Initializes a new, empty registry.
+ ///
+ public DaqifiDeviceRegistry()
+ : this(null)
+ {
+ }
+
+ ///
+ /// Initializes a new, empty registry with a custom connector (test seam).
+ ///
+ ///
+ /// The connector opens connections with, or null for the
+ /// default factory connect.
+ ///
+ internal DaqifiDeviceRegistry(DeviceConnector? connector)
+ {
+ _connector = connector ?? ((info, options, cancellationToken) =>
+ DaqifiDeviceFactory.ConnectFromDeviceInfoAsync(info, options, cancellationToken));
+ }
+
+ #endregion
+
+ #region Public Surface
+
+ ///
+ /// Gets or sets the policy consulted when a device being registered turns out to be a
+ /// duplicate of one already in the live set. When null (the default) the existing
+ /// connection is kept and the new one is rejected — the safe choice that needs no callback.
+ ///
+ ///
+ /// Invoked without the registry lock held, so it may block on a user prompt. It is called for
+ /// both the pre-connect and post-connect checks; use
+ /// to tell them apart. An exception thrown by the policy propagates to the caller, and the
+ /// device being registered is disposed.
+ ///
+ public Func? DuplicatePolicy { get; set; }
+
+ ///
+ /// Occurs after a device has been added to the live set.
+ ///
+ ///
+ /// Raised without the registry lock held. An exception thrown by a subscriber is swallowed so
+ /// one faulty handler cannot fail the registration or suppress sibling notifications.
+ ///
+ public event EventHandler? DeviceAdded;
+
+ ///
+ /// Occurs after a device has been removed from the live set, disconnected, and disposed.
+ /// Not raised by , which tears the whole registry down.
+ ///
+ ///
+ /// Raised without the registry lock held. An exception thrown by a subscriber is swallowed so
+ /// one faulty handler cannot suppress sibling notifications.
+ ///
+ public event EventHandler? DeviceRemoved;
+
+ ///
+ /// Gets a point-in-time snapshot of the live set. The returned list is a copy and is safe to
+ /// enumerate from any thread.
+ ///
+ public IReadOnlyList Devices
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return new List(_devices.Values);
+ }
+ }
+ }
+
+ ///
+ /// Gets the number of devices currently in the live set.
+ ///
+ public int Count
+ {
+ get
+ {
+ lock (_lock)
+ {
+ return _devices.Count;
+ }
+ }
+ }
+
+ ///
+ /// Connects to a discovered device and adds it to the live set, running the duplicate check
+ /// twice: once from before connecting, and again from the
+ /// device's afterwards — the serial number of a serial-port
+ /// device is often only known once it has answered its first status message.
+ ///
+ /// The device information from discovery.
+ ///
+ /// The key to file the device under. When null, is used
+ /// (or a minted unique key when the device reports no identity at all).
+ ///
+ /// Optional connection options passed through to the connect step.
+ /// A cancellation token to cancel the connection.
+ ///
+ /// The outcome. On the result carries
+ /// the existing registration — the live connection to the same unit — and the device just
+ /// connected has already been disposed.
+ ///
+ /// is null.
+ /// The registry has been disposed.
+ public async Task ConnectAsync(
+ IDeviceInfo deviceInfo,
+ string? key = null,
+ DeviceConnectionOptions? options = null,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(deviceInfo);
+ ThrowIfDisposed();
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var discoveryIdentity = DeviceIdentity.FromDiscovery(deviceInfo);
+
+ // Pre-connect check. Rejecting here is free: nothing has been opened yet. The check that
+ // actually guards the live set runs after connecting, atomically with the add.
+ DeviceRegistration? approvedReplacement = null;
+
+ while (true)
+ {
+ DeviceRegistration? existing;
+ List? stale;
+
+ lock (_lock)
+ {
+ ThrowIfDisposed();
+ stale = PruneLocked();
+ existing = FindDuplicateLocked(key, discoveryIdentity);
+ }
+
+ CompleteRemoval(stale, DeviceRemovalReason.Disconnected);
+
+ if (existing == null)
+ {
+ break;
+ }
+
+ var action = InvokePolicy(new DuplicateDeviceCheck(
+ existing,
+ discoveryIdentity,
+ deviceInfo,
+ newDevice: null,
+ deviceInfo.ConnectionType,
+ DuplicateCheckPhase.BeforeConnect));
+
+ if (action == DuplicateDeviceAction.Cancel)
+ {
+ return new DeviceRegistrationResult(DeviceRegistrationOutcome.Canceled, null);
+ }
+
+ if (action == DuplicateDeviceAction.SwitchToNew)
+ {
+ // Record the decision but keep the existing connection until the new one is
+ // actually open. Dropping it first would leave the caller with nothing when the
+ // new transport fails to connect — switching a healthy USB session to a WiFi
+ // address that turns out not to answer must not cost them the session they had.
+ // Registration drops it once the replacement is live, without asking the policy a
+ // second time. If it vanishes in the meantime the reference simply matches nothing
+ // there, and the new device is registered on its own merits.
+ approvedReplacement = existing;
+ break;
+ }
+
+ // KeepExisting. Only hand that registration back if it is still the live one: the
+ // policy ran without the lock and is documented as free to block on a user prompt, so
+ // another thread (or the policy itself) may have removed or replaced it meanwhile, and
+ // returning it then would deliver a disposed handle as "the live connection". When it
+ // has gone, re-resolve against the current set instead — which either finds a
+ // different duplicate to ask about or clears the way to connect.
+ if (IsStillRegistered(existing))
+ {
+ return new DeviceRegistrationResult(DeviceRegistrationOutcome.DuplicateRejected, existing);
+ }
+ }
+
+ // Re-check both shutdown signals immediately before opening anything. The policy above runs
+ // without the lock and may block indefinitely on a user prompt, so a registry disposed — or
+ // a token cancelled — during that window would otherwise still reach the connector.
+ // This narrows the window rather than closing it: either signal landing between here and
+ // the connector, or during the connect itself, still lets the connection open, and
+ // RegisterCore then throws and disposes it. Guaranteeing no transport activity outlives the
+ // registry needs in-flight tracking on Dispose, which is out of scope here.
+ lock (_lock)
+ {
+ ThrowIfDisposed();
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var device = await _connector(deviceInfo, options, cancellationToken).ConfigureAwait(false);
+
+ return RegisterCore(device, deviceInfo, key, approvedReplacement);
+ }
+
+ ///
+ /// Adds an already-connected device to the live set, running the duplicate check against its
+ /// . Use this when the connection was opened elsewhere (for
+ /// example by directly); is the
+ /// one-call path.
+ ///
+ /// The connected device. The registry takes ownership of it.
+ ///
+ /// The discovery metadata the device was connected from, if available. Supplying it lets the
+ /// registry report the device's transport and keeps discovery-only discriminators (the USB
+ /// location key, and the MAC address on a device whose metadata has not filled one in yet).
+ ///
+ ///
+ /// The key to file the device under. When null, is used
+ /// (or a minted unique key when the device reports no identity at all).
+ ///
+ ///
+ /// The outcome, in the same shape returns. Registering a device
+ /// that is already in the live set is an idempotent no-op that reports
+ /// with its existing registration.
+ ///
+ /// is null.
+ /// is not connected.
+ /// The registry has been disposed.
+ public DeviceRegistrationResult Register(
+ DaqifiDevice device,
+ IDeviceInfo? deviceInfo = null,
+ string? key = null)
+ {
+ ArgumentNullException.ThrowIfNull(device);
+ ThrowIfDisposed();
+
+ // Validate before taking ownership, so a caller mistake never costs them their device.
+ if (!device.IsConnected)
+ {
+ throw new ArgumentException(
+ "Only a connected device can be registered. Connect the device first.", nameof(device));
+ }
+
+ return RegisterCore(device, deviceInfo, key, approvedReplacement: null);
+ }
+
+ ///
+ /// Looks up a registration by key.
+ ///
+ /// The key the device was filed under.
+ /// The registration, or null when the key is unknown.
+ /// true when a registration was found.
+ ///
+ /// A lookup never prunes, so a device that dropped since it was registered is still returned;
+ /// check if that matters to the caller.
+ ///
+ public bool TryGet(string key, out DeviceRegistration? registration)
+ {
+ if (string.IsNullOrEmpty(key))
+ {
+ registration = null;
+ return false;
+ }
+
+ lock (_lock)
+ {
+ return _devices.TryGetValue(key, out registration);
+ }
+ }
+
+ ///
+ /// Looks up a device by key. Shorthand for followed by
+ /// .
+ ///
+ /// The key the device was filed under.
+ /// The device, or null when the key is unknown.
+ /// true when a device was found.
+ public bool TryGetDevice(string key, out DaqifiDevice? device)
+ {
+ var found = TryGet(key, out var registration);
+ device = registration?.Device;
+ return found;
+ }
+
+ ///
+ /// Finds the registration whose device is the same physical unit as
+ /// — the duplicate-detection primitive, exposed for consumers that
+ /// want to check before opening a connection of their own.
+ ///
+ /// The identity to match against.
+ /// The matching registration, or null when the unit is not in the live set.
+ public DeviceRegistration? Find(DeviceIdentity identity)
+ {
+ if (identity is null || identity.IsEmpty)
+ {
+ return null;
+ }
+
+ lock (_lock)
+ {
+ return FindByIdentityLocked(identity);
+ }
+ }
+
+ ///
+ /// Removes a device from the live set, disconnecting and disposing it.
+ ///
+ /// The key the device was filed under.
+ /// true when a device was removed.
+ public bool Remove(string key)
+ {
+ if (string.IsNullOrEmpty(key))
+ {
+ return false;
+ }
+
+ DeviceRegistration? removed;
+ lock (_lock)
+ {
+ if (!_devices.Remove(key, out removed))
+ {
+ return false;
+ }
+ }
+
+ CompleteRemoval(new List { removed! }, DeviceRemovalReason.Removed);
+ return true;
+ }
+
+ ///
+ /// Removes a device from the live set by reference, disconnecting and disposing it.
+ ///
+ /// The device to remove.
+ /// true when the device was in the live set and was removed.
+ public bool Remove(DaqifiDevice device)
+ {
+ if (device is null)
+ {
+ return false;
+ }
+
+ DeviceRegistration? removed = null;
+ lock (_lock)
+ {
+ foreach (var registration in _devices.Values)
+ {
+ if (ReferenceEquals(registration.Device, device))
+ {
+ removed = registration;
+ break;
+ }
+ }
+
+ if (removed == null)
+ {
+ return false;
+ }
+
+ _devices.Remove(removed.Key);
+ }
+
+ CompleteRemoval(new List { removed }, DeviceRemovalReason.Removed);
+ return true;
+ }
+
+ ///
+ /// Removes every registration whose device is no longer connected, disposing each one. Called
+ /// automatically before each registration attempt; exposed for consumers that poll the live set.
+ ///
+ /// The number of stale registrations removed.
+ public int PruneDisconnected()
+ {
+ List? stale;
+ lock (_lock)
+ {
+ stale = PruneLocked();
+ }
+
+ CompleteRemoval(stale, DeviceRemovalReason.Disconnected);
+ return stale?.Count ?? 0;
+ }
+
+ ///
+ /// Removes every device from the live set, disconnecting and disposing each one.
+ ///
+ public void Clear()
+ {
+ List removed;
+ lock (_lock)
+ {
+ if (_devices.Count == 0)
+ {
+ return;
+ }
+
+ removed = new List(_devices.Values);
+ _devices.Clear();
+ }
+
+ CompleteRemoval(removed, DeviceRemovalReason.Cleared);
+ }
+
+ ///
+ /// Disconnects and disposes every device in the live set and marks the registry unusable.
+ /// is not raised: the registry itself is going away, so there is no
+ /// live set left to keep in sync.
+ ///
+ ///
+ /// Does not wait for work already in flight. A that has already
+ /// reached its connector finishes opening the connection, and is then rejected and disposed by
+ /// the registration step — so nothing leaks, but the transport activity does complete. Callers
+ /// that need connect attempts to stop promptly should cancel the token they passed in.
+ ///
+ public void Dispose()
+ {
+ List removed;
+ lock (_lock)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ removed = new List(_devices.Values);
+ _devices.Clear();
+ }
+
+ foreach (var registration in removed)
+ {
+ DisposeDevice(registration.Device);
+ }
+ }
+
+ #endregion
+
+ #region Registration
+
+ ///
+ /// Runs the post-connect duplicate check and adds the device, taking ownership of it: on any
+ /// outcome other than a successful add — including a policy callback that throws — the device
+ /// is disconnected and disposed.
+ ///
+ /// The connected device being registered.
+ /// The discovery metadata, if available.
+ /// The requested key, or null to derive one.
+ ///
+ /// A registration the pre-connect check already got approval to replace. Finding it again here
+ /// is not a fresh duplicate: it is dropped without re-consulting the policy, so a consumer that
+ /// prompts its user is asked once per connection attempt rather than twice.
+ ///
+ private DeviceRegistrationResult RegisterCore(
+ DaqifiDevice device,
+ IDeviceInfo? deviceInfo,
+ string? key,
+ DeviceRegistration? approvedReplacement)
+ {
+ var replacedExisting = false;
+
+ try
+ {
+ // Metadata is authoritative once connected, but it never carries the USB location key
+ // and may not have filled in a MAC address yet, so fall back to what discovery saw.
+ var identity = DeviceIdentity
+ .FromMetadata(device.Metadata, deviceInfo?.LocationKey)
+ .MergeWith(deviceInfo != null ? DeviceIdentity.FromDiscovery(deviceInfo) : null);
+
+ var connectionType = deviceInfo?.ConnectionType ?? ConnectionType.Unknown;
+
+ while (true)
+ {
+ DeviceRegistration? existing;
+ DeviceRegistration? added = null;
+ DeviceRegistration? alreadyRegistered = null;
+ List? stale;
+
+ lock (_lock)
+ {
+ ThrowIfDisposed();
+ stale = PruneLocked();
+ existing = FindDuplicateLocked(key, identity, device);
+
+ if (existing == null)
+ {
+ // Resolving the key and adding under the same lock as the duplicate check
+ // is what makes concurrent registrations safe: no second thread can slip a
+ // duplicate in between the two steps.
+ added = new DeviceRegistration(ResolveKeyLocked(key, identity), device, identity, deviceInfo);
+ _devices.Add(added.Key, added);
+ }
+ else if (ReferenceEquals(existing.Device, device))
+ {
+ // This exact device is already in the live set. Re-registering it is a
+ // no-op — emphatically not a duplicate to dispose, which would leave the
+ // registry holding a dead handle.
+ alreadyRegistered = existing;
+ }
+ }
+
+ CompleteRemoval(stale, DeviceRemovalReason.Disconnected);
+
+ if (alreadyRegistered != null)
+ {
+ return new DeviceRegistrationResult(
+ DeviceRegistrationOutcome.Registered, alreadyRegistered);
+ }
+
+ if (added != null)
+ {
+ RaiseDeviceAdded(added);
+ return new DeviceRegistrationResult(
+ replacedExisting
+ ? DeviceRegistrationOutcome.ReplacedExisting
+ : DeviceRegistrationOutcome.Registered,
+ added);
+ }
+
+ // A registration the caller already approved replacing before we connected is not
+ // a fresh duplicate — dropping it now is finishing that decision, not making one.
+ var action = ReferenceEquals(existing, approvedReplacement)
+ ? DuplicateDeviceAction.SwitchToNew
+ : InvokePolicy(new DuplicateDeviceCheck(
+ existing!,
+ identity,
+ deviceInfo,
+ device,
+ connectionType,
+ DuplicateCheckPhase.AfterConnect));
+
+ switch (action)
+ {
+ case DuplicateDeviceAction.SwitchToNew:
+ RemoveRegistration(existing!, DeviceRemovalReason.Replaced);
+ replacedExisting = true;
+ continue;
+
+ case DuplicateDeviceAction.Cancel:
+ DisposeDevice(device);
+ return new DeviceRegistrationResult(DeviceRegistrationOutcome.Canceled, null);
+
+ default:
+ // KeepExisting. Same revalidation as the pre-connect path: the policy ran
+ // without the lock, so only hand back a registration that is still the
+ // live one. If it has gone, loop rather than disposing the device we just
+ // connected — re-resolving may now find no duplicate at all, in which case
+ // this device is what the caller should end up with.
+ if (!IsStillRegistered(existing!))
+ {
+ continue;
+ }
+
+ DisposeDevice(device);
+ return new DeviceRegistrationResult(
+ DeviceRegistrationOutcome.DuplicateRejected, existing);
+ }
+ }
+ }
+ catch
+ {
+ // Ownership already transferred, so the device is ours to clean up.
+ DisposeDevice(device);
+ throw;
+ }
+ }
+
+ ///
+ /// Finds the registration a device being registered would collide with: the entry already
+ /// filed under the requested key, the entry holding this very device instance, or one
+ /// describing the same physical unit. Caller must hold .
+ ///
+ private DeviceRegistration? FindDuplicateLocked(string? key, DeviceIdentity identity, DaqifiDevice? device = null)
+ {
+ if (!string.IsNullOrEmpty(key) && _devices.TryGetValue(key!, out var byKey))
+ {
+ return byKey;
+ }
+
+ if (device != null)
+ {
+ foreach (var registration in _devices.Values)
+ {
+ if (ReferenceEquals(registration.Device, device))
+ {
+ return registration;
+ }
+ }
+ }
+
+ return FindByIdentityLocked(identity);
+ }
+
+ ///
+ /// Finds the registration describing the same physical unit as .
+ /// Caller must hold .
+ ///
+ private DeviceRegistration? FindByIdentityLocked(DeviceIdentity identity)
+ {
+ if (identity.IsEmpty)
+ {
+ return null;
+ }
+
+ foreach (var registration in _devices.Values)
+ {
+ if (registration.Identity.Matches(identity))
+ {
+ return registration;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Picks the key a new registration is filed under. A caller-supplied key is used as-is (the
+ /// duplicate check has already proven it free); otherwise the identity key is used, falling
+ /// back to a minted unique key so devices that report no identity never collide. Caller must
+ /// hold .
+ ///
+ private string ResolveKeyLocked(string? requestedKey, DeviceIdentity identity)
+ {
+ if (!string.IsNullOrEmpty(requestedKey))
+ {
+ return requestedKey!;
+ }
+
+ if (!identity.IsEmpty && !_devices.ContainsKey(identity.Key))
+ {
+ return identity.Key;
+ }
+
+ string minted;
+ do
+ {
+ minted = "device-" + (++_mintedKeyCounter);
+ }
+ while (_devices.ContainsKey(minted));
+
+ return minted;
+ }
+
+ ///
+ /// Removes every registration whose device is no longer connected and returns them, or
+ /// null when there were none. Disposal and notification happen outside the lock, in
+ /// . Caller must hold .
+ ///
+ private List? PruneLocked()
+ {
+ List? stale = null;
+
+ foreach (var registration in _devices.Values)
+ {
+ if (!registration.Device.IsConnected)
+ {
+ (stale ??= new List()).Add(registration);
+ }
+ }
+
+ if (stale != null)
+ {
+ foreach (var registration in stale)
+ {
+ _devices.Remove(registration.Key);
+ }
+ }
+
+ return stale;
+ }
+
+ ///
+ /// Removes one registration and disposes its device.
+ ///
+ private void RemoveRegistration(DeviceRegistration registration, DeviceRemovalReason reason)
+ {
+ lock (_lock)
+ {
+ // Only remove the exact entry we resolved: a concurrent operation may already have
+ // replaced it with a different device under the same key.
+ if (!_devices.TryGetValue(registration.Key, out var current) || !ReferenceEquals(current, registration))
+ {
+ return;
+ }
+
+ _devices.Remove(registration.Key);
+ }
+
+ CompleteRemoval(new List { registration }, reason);
+ }
+
+ ///
+ /// Disposes the devices of already-removed registrations and announces them. Always called
+ /// without the lock held: disconnecting touches the transport, and subscribers must be free to
+ /// call back into the registry.
+ ///
+ private void CompleteRemoval(List? removed, DeviceRemovalReason reason)
+ {
+ if (removed == null || removed.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var registration in removed)
+ {
+ DisposeDevice(registration.Device);
+ RaiseDeviceRemoved(registration, reason);
+ }
+ }
+
+ #endregion
+
+ #region Helpers
+
+ ///
+ /// Reports whether a registration captured earlier is still the entry filed under its key.
+ /// Used to revalidate a registration across the duplicate-policy call, which runs without the
+ /// lock held and may block indefinitely, so the live set can change underneath it.
+ ///
+ /// The registration captured before the policy ran.
+ /// true when that exact instance is still in the live set.
+ private bool IsStillRegistered(DeviceRegistration registration)
+ {
+ lock (_lock)
+ {
+ return _devices.TryGetValue(registration.Key, out var current)
+ && ReferenceEquals(current, registration);
+ }
+ }
+
+ ///
+ /// Asks the duplicate policy what to do, defaulting to keeping the existing connection when no
+ /// policy is set or an out-of-range action is returned.
+ ///
+ private DuplicateDeviceAction InvokePolicy(DuplicateDeviceCheck check)
+ {
+ var policy = DuplicatePolicy;
+ if (policy == null)
+ {
+ return DuplicateDeviceAction.KeepExisting;
+ }
+
+ var action = policy(check);
+ return Enum.IsDefined(action) ? action : DuplicateDeviceAction.KeepExisting;
+ }
+
+ ///
+ /// Disconnects and disposes a device the registry owns. Disconnect failures are ignored: the
+ /// transport is being torn down either way, and disposal must still run.
+ ///
+ private static void DisposeDevice(DaqifiDevice device)
+ {
+ try
+ {
+ device.Disconnect();
+ }
+ catch
+ {
+ // Best effort — Dispose below tears the transport down regardless.
+ }
+
+ try
+ {
+ device.Dispose();
+ }
+ catch
+ {
+ // Best effort — a device that fails to dispose must not fail the registry operation.
+ }
+ }
+
+ ///
+ /// Raises , swallowing subscriber exceptions so a faulty handler
+ /// cannot fail a registration that has already happened.
+ ///
+ private void RaiseDeviceAdded(DeviceRegistration registration)
+ {
+ try
+ {
+ DeviceAdded?.Invoke(this, new DeviceRegisteredEventArgs(registration));
+ }
+ catch
+ {
+ // Intentionally swallowed: the device is registered either way.
+ }
+ }
+
+ ///
+ /// Raises , swallowing subscriber exceptions so a faulty handler
+ /// cannot suppress sibling notifications.
+ ///
+ private void RaiseDeviceRemoved(DeviceRegistration registration, DeviceRemovalReason reason)
+ {
+ try
+ {
+ DeviceRemoved?.Invoke(this, new DeviceRemovedEventArgs(registration, reason));
+ }
+ catch
+ {
+ // Intentionally swallowed: the device is already removed and disposed.
+ }
+ }
+
+ ///
+ /// Throws if this registry has been disposed.
+ ///
+ private void ThrowIfDisposed()
+ {
+ if (_disposed)
+ {
+ throw new ObjectDisposedException(nameof(DaqifiDeviceRegistry));
+ }
+ }
+
+ #endregion
+}
diff --git a/src/Daqifi.Core/Device/DeviceIdentity.cs b/src/Daqifi.Core/Device/DeviceIdentity.cs
new file mode 100644
index 00000000..896f89a6
--- /dev/null
+++ b/src/Daqifi.Core/Device/DeviceIdentity.cs
@@ -0,0 +1,224 @@
+using Daqifi.Core.Device.Discovery;
+
+namespace Daqifi.Core.Device;
+
+///
+/// Fingerprints a physical DAQiFi unit independently of the transport it was reached over, so the
+/// same device seen via USB and via WiFi can be recognized as one unit rather than two.
+///
+///
+/// Three discriminators are used, in decreasing order of reliability:
+///
+/// -
+/// Serial number — reported by every transport once the device has answered a status message
+/// ( pre-connect,
+/// post-connect). Compared case-insensitively.
+///
+/// -
+/// MAC address — network-only, but present before a serial number is known on the WiFi path.
+/// Separators are ignored, so AA-BB-CC-DD-EE-FF and aa:bb:cc:dd:ee:ff compare equal.
+///
+/// -
+/// USB physical-location key () — the last resort for
+/// identical USB units that report no serial number. Windows-only in v1; null elsewhere.
+///
+///
+/// Matching walks that list and is decided by the first discriminator that both identities
+/// carry: two units with different serial numbers are never the same device even if some later
+/// discriminator happens to agree. When no discriminator is populated on both sides the identities
+/// simply do not match — an unidentifiable device never collides with another unidentifiable one.
+///
+public sealed class DeviceIdentity
+{
+ ///
+ /// An identity with no discriminators at all. Never matches anything, including itself.
+ ///
+ public static readonly DeviceIdentity Empty = new(null, null, null);
+
+ private DeviceIdentity(string? serialNumber, string? macAddress, string? locationKey)
+ {
+ SerialNumber = Normalize(serialNumber);
+ MacAddress = Normalize(macAddress);
+ LocationKey = Normalize(locationKey);
+
+ Key = SerialNumber != null ? "sn:" + SerialNumber.ToLowerInvariant()
+ : MacAddress != null ? "mac:" + NormalizeMac(MacAddress)
+ : LocationKey != null ? "loc:" + LocationKey.ToLowerInvariant()
+ : string.Empty;
+ }
+
+ ///
+ /// Gets the device serial number, or null when the device did not report one.
+ ///
+ public string? SerialNumber { get; }
+
+ ///
+ /// Gets the device MAC address, or null when the device did not report one.
+ ///
+ public string? MacAddress { get; }
+
+ ///
+ /// Gets the USB physical-location key, or null when it could not be resolved.
+ ///
+ public string? LocationKey { get; }
+
+ ///
+ /// Gets a stable key derived from the strongest discriminator this identity carries
+ /// (for example sn:1234), or when it carries none.
+ /// Two identities with the same non-empty key always .
+ ///
+ public string Key { get; }
+
+ ///
+ /// Gets a value indicating whether this identity carries no discriminator at all and can
+ /// therefore never be matched against another device.
+ ///
+ public bool IsEmpty => Key.Length == 0;
+
+ ///
+ /// Creates an identity from raw discriminators. Null, empty, and whitespace-only values are
+ /// all treated as "not reported".
+ ///
+ /// The device serial number, if known.
+ /// The device MAC address, if known.
+ /// The USB physical-location key, if known.
+ /// The identity described by the supplied discriminators.
+ public static DeviceIdentity Create(string? serialNumber, string? macAddress = null, string? locationKey = null)
+ => new(serialNumber, macAddress, locationKey);
+
+ ///
+ /// Creates an identity from discovery metadata — the identity available before a
+ /// device is connected.
+ ///
+ /// The discovered device information.
+ /// The identity described by .
+ /// is null.
+ public static DeviceIdentity FromDiscovery(IDeviceInfo deviceInfo)
+ {
+ ArgumentNullException.ThrowIfNull(deviceInfo);
+ return new DeviceIdentity(deviceInfo.SerialNumber, deviceInfo.MacAddress, deviceInfo.LocationKey);
+ }
+
+ ///
+ /// Creates an identity from a connected device's metadata — the identity available
+ /// after the device has answered its initial status message. Metadata carries no USB
+ /// location key, so pass through the one discovery reported (if any) to keep that discriminator.
+ ///
+ /// The connected device's metadata.
+ /// The USB physical-location key from discovery, if known.
+ /// The identity described by .
+ /// is null.
+ public static DeviceIdentity FromMetadata(DeviceMetadata metadata, string? locationKey = null)
+ {
+ ArgumentNullException.ThrowIfNull(metadata);
+ return new DeviceIdentity(metadata.SerialNumber, metadata.MacAddress, locationKey);
+ }
+
+ ///
+ /// Determines whether this identity and describe the same physical
+ /// unit, using the first discriminator both identities carry. Returns false when they
+ /// share no populated discriminator, so unidentifiable devices never match each other.
+ ///
+ /// The identity to compare against.
+ /// true when both identities describe the same physical device.
+ public bool Matches(DeviceIdentity? other)
+ {
+ if (other == null)
+ {
+ return false;
+ }
+
+ if (SerialNumber != null && other.SerialNumber != null)
+ {
+ return SerialNumber.Equals(other.SerialNumber, StringComparison.OrdinalIgnoreCase);
+ }
+
+ if (MacAddress != null && other.MacAddress != null)
+ {
+ return NormalizeMac(MacAddress).Equals(NormalizeMac(other.MacAddress), StringComparison.Ordinal);
+ }
+
+ if (LocationKey != null && other.LocationKey != null)
+ {
+ return LocationKey.Equals(other.LocationKey, StringComparison.OrdinalIgnoreCase);
+ }
+
+ return false;
+ }
+
+ ///
+ /// Returns an identity that keeps this instance's discriminators and fills in any it is
+ /// missing from . Used to carry a pre-connect discriminator (such as
+ /// the USB location key, which metadata never reports) into the post-connect identity.
+ ///
+ /// The identity to take missing discriminators from.
+ /// The merged identity.
+ public DeviceIdentity MergeWith(DeviceIdentity? fallback)
+ {
+ if (fallback == null)
+ {
+ return this;
+ }
+
+ return new DeviceIdentity(
+ SerialNumber ?? fallback.SerialNumber,
+ MacAddress ?? fallback.MacAddress,
+ LocationKey ?? fallback.LocationKey);
+ }
+
+ ///
+ /// Returns a diagnostic string listing the populated discriminators.
+ ///
+ public override string ToString()
+ {
+ if (IsEmpty)
+ {
+ return "(unidentified)";
+ }
+
+ var parts = new List(3);
+ if (SerialNumber != null)
+ {
+ parts.Add("sn=" + SerialNumber);
+ }
+ if (MacAddress != null)
+ {
+ parts.Add("mac=" + MacAddress);
+ }
+ if (LocationKey != null)
+ {
+ parts.Add("location=" + LocationKey);
+ }
+
+ return string.Join(", ", parts);
+ }
+
+ ///
+ /// Trims a discriminator and collapses "not reported" spellings (null, empty, whitespace)
+ /// onto null so they are never compared as values.
+ ///
+ private static string? Normalize(string? value)
+ => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ ///
+ /// Strips separators and case from a MAC address so the hyphenated form Core produces
+ /// compares equal to the colon-separated form other tooling uses.
+ ///
+ private static string NormalizeMac(string macAddress)
+ {
+ var buffer = new char[macAddress.Length];
+ var length = 0;
+
+ foreach (var c in macAddress)
+ {
+ if (c is '-' or ':' or '.' or ' ')
+ {
+ continue;
+ }
+
+ buffer[length++] = char.ToLowerInvariant(c);
+ }
+
+ return new string(buffer, 0, length);
+ }
+}
diff --git a/src/Daqifi.Core/Device/DeviceRegistration.cs b/src/Daqifi.Core/Device/DeviceRegistration.cs
new file mode 100644
index 00000000..38cce022
--- /dev/null
+++ b/src/Daqifi.Core/Device/DeviceRegistration.cs
@@ -0,0 +1,53 @@
+using Daqifi.Core.Device.Discovery;
+
+namespace Daqifi.Core.Device;
+
+///
+/// One entry in a : a connected device, the key it is filed
+/// under, and the identity used to recognize it across transports.
+///
+public sealed class DeviceRegistration
+{
+ internal DeviceRegistration(string key, DaqifiDevice device, DeviceIdentity identity, IDeviceInfo? discoveryInfo)
+ {
+ Key = key;
+ Device = device;
+ Identity = identity;
+ DiscoveryInfo = discoveryInfo;
+ }
+
+ ///
+ /// Gets the key this device is filed under — the handle callers pass to
+ /// and .
+ /// Defaults to unless the caller supplied its own.
+ ///
+ public string Key { get; }
+
+ ///
+ /// Gets the connected device. The registry owns it: it is disconnected and disposed when the
+ /// registration is removed.
+ ///
+ public DaqifiDevice Device { get; }
+
+ ///
+ /// Gets the identity used to recognize this physical unit across transports.
+ ///
+ public DeviceIdentity Identity { get; }
+
+ ///
+ /// Gets the discovery metadata this device was connected from, or null when an
+ /// already-connected device was registered without it.
+ ///
+ public IDeviceInfo? DiscoveryInfo { get; }
+
+ ///
+ /// Gets the transport this device is connected over, or
+ /// when it was registered without discovery metadata.
+ ///
+ public ConnectionType ConnectionType => DiscoveryInfo?.ConnectionType ?? ConnectionType.Unknown;
+
+ ///
+ /// Returns a diagnostic string naming the key, transport, and identity.
+ ///
+ public override string ToString() => $"{Key} [{ConnectionType}] {Identity}";
+}
diff --git a/src/Daqifi.Core/Device/DeviceRegistrationResult.cs b/src/Daqifi.Core/Device/DeviceRegistrationResult.cs
new file mode 100644
index 00000000..d353dcff
--- /dev/null
+++ b/src/Daqifi.Core/Device/DeviceRegistrationResult.cs
@@ -0,0 +1,84 @@
+namespace Daqifi.Core.Device;
+
+///
+/// How a registration attempt ended.
+///
+public enum DeviceRegistrationOutcome
+{
+ ///
+ /// The device was added to the live set.
+ ///
+ Registered,
+
+ ///
+ /// The device was added to the live set after a duplicate registration was dropped, because
+ /// the duplicate policy returned .
+ ///
+ ReplacedExisting,
+
+ ///
+ /// The device was rejected as a duplicate of one already in the live set, because the
+ /// duplicate policy returned (the default).
+ /// The existing registration is reported in .
+ ///
+ DuplicateRejected,
+
+ ///
+ /// The registration was abandoned because the duplicate policy returned
+ /// .
+ ///
+ Canceled
+}
+
+///
+/// The result of a registration attempt.
+///
+public sealed class DeviceRegistrationResult
+{
+ internal DeviceRegistrationResult(DeviceRegistrationOutcome outcome, DeviceRegistration? registration)
+ {
+ Outcome = outcome;
+ Registration = registration;
+ }
+
+ ///
+ /// Gets how the attempt ended.
+ ///
+ public DeviceRegistrationOutcome Outcome { get; }
+
+ ///
+ /// Gets the registration the caller should now use, or null when the attempt was
+ /// canceled. On this is the existing
+ /// registration that won — the live connection to the same physical unit — not the rejected
+ /// device, which the registry has already disposed.
+ ///
+ public DeviceRegistration? Registration { get; }
+
+ ///
+ /// Gets the device the caller should now use, or null when the attempt was canceled.
+ /// Shorthand for on .
+ ///
+ public DaqifiDevice? Device => Registration?.Device;
+
+ ///
+ /// Gets the key is filed under, or null when the attempt was
+ /// canceled.
+ ///
+ public string? Key => Registration?.Key;
+
+ ///
+ /// Gets a value indicating whether the attempt added the device that was passed in, as opposed
+ /// to rejecting it in favor of an existing registration or canceling.
+ ///
+ public bool IsRegistered =>
+ Outcome is DeviceRegistrationOutcome.Registered or DeviceRegistrationOutcome.ReplacedExisting;
+
+ ///
+ /// Gets a value indicating whether a duplicate of the device was already in the live set,
+ /// whichever connection ended up winning.
+ ///
+ public bool WasDuplicate =>
+ Outcome is DeviceRegistrationOutcome.ReplacedExisting
+ or DeviceRegistrationOutcome.DuplicateRejected
+ or DeviceRegistrationOutcome.Canceled;
+}
diff --git a/src/Daqifi.Core/Device/DeviceRegistryEventArgs.cs b/src/Daqifi.Core/Device/DeviceRegistryEventArgs.cs
new file mode 100644
index 00000000..cfeed810
--- /dev/null
+++ b/src/Daqifi.Core/Device/DeviceRegistryEventArgs.cs
@@ -0,0 +1,82 @@
+namespace Daqifi.Core.Device;
+
+///
+/// Why a device left a .
+///
+public enum DeviceRemovalReason
+{
+ ///
+ /// The caller removed it explicitly.
+ ///
+ Removed,
+
+ ///
+ /// It was dropped in favor of a duplicate connection to the same physical unit, because the
+ /// duplicate policy returned .
+ ///
+ Replaced,
+
+ ///
+ /// The registry found the device no longer reporting
+ /// (unplugged, rebooted, or dropped by the transport) and pruned the stale registration.
+ ///
+ Disconnected,
+
+ ///
+ /// The whole live set was cleared by .
+ ///
+ Cleared
+}
+
+///
+/// Event data for .
+///
+public sealed class DeviceRegisteredEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The registration that was added.
+ public DeviceRegisteredEventArgs(DeviceRegistration registration)
+ {
+ Registration = registration;
+ }
+
+ ///
+ /// Gets the registration that was added to the live set.
+ ///
+ public DeviceRegistration Registration { get; }
+}
+
+///
+/// Event data for .
+///
+///
+/// The registry disconnects and disposes the device before raising this event, so subscribers must
+/// treat as a dead handle — read its
+/// or for a message, but
+/// do not send to it.
+///
+public sealed class DeviceRemovedEventArgs : EventArgs
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The registration that was removed.
+ /// Why it was removed.
+ public DeviceRemovedEventArgs(DeviceRegistration registration, DeviceRemovalReason reason)
+ {
+ Registration = registration;
+ Reason = reason;
+ }
+
+ ///
+ /// Gets the registration that was removed from the live set.
+ ///
+ public DeviceRegistration Registration { get; }
+
+ ///
+ /// Gets why the device was removed.
+ ///
+ public DeviceRemovalReason Reason { get; }
+}
diff --git a/src/Daqifi.Core/Device/DuplicateDeviceCheck.cs b/src/Daqifi.Core/Device/DuplicateDeviceCheck.cs
new file mode 100644
index 00000000..667de0f4
--- /dev/null
+++ b/src/Daqifi.Core/Device/DuplicateDeviceCheck.cs
@@ -0,0 +1,110 @@
+using Daqifi.Core.Device.Discovery;
+
+namespace Daqifi.Core.Device;
+
+///
+/// The action a takes when the registry is asked
+/// to register a device that is already in the live set under a different transport.
+///
+public enum DuplicateDeviceAction
+{
+ ///
+ /// Keep the already-registered connection and reject the new one. This is the default when no
+ /// policy is supplied.
+ ///
+ KeepExisting,
+
+ ///
+ /// Drop the already-registered connection (disconnecting and disposing it) and continue with
+ /// the new one. The existing connection is not dropped until the new one is actually open, so
+ /// a replacement that fails to connect leaves the original in place rather than costing the
+ /// caller both.
+ ///
+ SwitchToNew,
+
+ ///
+ /// Abandon the registration entirely, leaving the existing connection in place. Differs from
+ /// only in the reported outcome: the caller asked to cancel rather
+ /// than being handed the existing device.
+ ///
+ Cancel
+}
+
+///
+/// When a duplicate was detected, relative to the connection attempt.
+///
+public enum DuplicateCheckPhase
+{
+ ///
+ /// Detected from discovery metadata before the new connection was opened. Nothing has been
+ /// connected yet, so rejecting costs nothing.
+ ///
+ BeforeConnect,
+
+ ///
+ /// Detected from device metadata after the new connection was opened — the case where the
+ /// serial number only becomes known once the device answers its first status message.
+ ///
+ AfterConnect
+}
+
+///
+/// Describes a detected duplicate: the same physical unit reached over two transports at once
+/// (typically USB and WiFi). Passed to so a
+/// consumer can decide which connection wins — for example by prompting the user.
+///
+public sealed class DuplicateDeviceCheck
+{
+ internal DuplicateDeviceCheck(
+ DeviceRegistration existing,
+ DeviceIdentity newIdentity,
+ IDeviceInfo? newDeviceInfo,
+ DaqifiDevice? newDevice,
+ ConnectionType newConnectionType,
+ DuplicateCheckPhase phase)
+ {
+ Existing = existing;
+ NewIdentity = newIdentity;
+ NewDeviceInfo = newDeviceInfo;
+ NewDevice = newDevice;
+ NewConnectionType = newConnectionType;
+ Phase = phase;
+ }
+
+ ///
+ /// Gets the registration already in the live set.
+ ///
+ public DeviceRegistration Existing { get; }
+
+ ///
+ /// Gets the identity computed for the device being registered.
+ ///
+ public DeviceIdentity NewIdentity { get; }
+
+ ///
+ /// Gets the discovery metadata the new connection was made from, or null when an
+ /// already-connected device was registered without it.
+ ///
+ public IDeviceInfo? NewDeviceInfo { get; }
+
+ ///
+ /// Gets the device being registered, or null when the duplicate was caught in
+ /// and nothing has been connected yet.
+ ///
+ public DaqifiDevice? NewDevice { get; }
+
+ ///
+ /// Gets the transport the new connection uses.
+ ///
+ public ConnectionType NewConnectionType { get; }
+
+ ///
+ /// Gets the transport the existing connection uses.
+ ///
+ public ConnectionType ExistingConnectionType => Existing.ConnectionType;
+
+ ///
+ /// Gets when the duplicate was detected, relative to the new connection being opened.
+ ///
+ public DuplicateCheckPhase Phase { get; }
+}
diff --git a/src/Daqifi.Mcp/DaqifiAgent.cs b/src/Daqifi.Mcp/DaqifiAgent.cs
index f5406b96..e96b2a89 100644
--- a/src/Daqifi.Mcp/DaqifiAgent.cs
+++ b/src/Daqifi.Mcp/DaqifiAgent.cs
@@ -9,7 +9,7 @@ namespace Daqifi.Mcp;
///
/// Agent-facing facade over Daqifi.Core. Owns device discovery results and the set of
/// connected devices, and translates the high-level tool surface into the real SDK calls
-/// (static , channel APIs,
+/// (, channel APIs,
/// ). One instance is shared by all tool calls.
///
///
@@ -17,12 +17,15 @@ namespace Daqifi.Mcp;
/// disconnects, or mutates device state is serialized behind . Read-only
/// introspection snapshots the channel collection instead, so it never blocks and never folds the
/// live Channels view while the device's consumer thread repopulates it.
+/// The live set of connections is owned by a keyed by our own
+/// device_id, which also supplies stale-handle pruning, disposal, and cross-transport
+/// duplicate detection (the same unit reached over both USB and WiFi).
///
public sealed class DaqifiAgent
{
private readonly ServerOptions _options;
private readonly ConcurrentDictionary _discovered = new(StringComparer.Ordinal);
- private readonly ConcurrentDictionary _connected = new(StringComparer.Ordinal);
+ private readonly DaqifiDeviceRegistry _registry = new();
private readonly SemaphoreSlim _gate = new(1, 1);
public DaqifiAgent(ServerOptions options) => _options = options;
@@ -61,35 +64,37 @@ public async Task> DiscoverAsync(
// ------------------------------------------------------------- connection
+ ///
+ /// Connects to a discovered device and files it in the registry under its device_id.
+ /// Reconnecting an id that is already live (or connecting a second transport to a device that
+ /// is already connected) is not an error: the registry's default duplicate policy hands back
+ /// the existing connection, whose id is the one returned — so always use the returned
+ /// device_id for follow-up calls rather than assuming it is the one passed in.
+ ///
public async Task ConnectAsync(string deviceId, CancellationToken cancellationToken)
{
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- if (_connected.TryGetValue(deviceId, out var already))
- {
- if (already.IsConnected)
- {
- return ConnectedDeviceInfo.From(deviceId, already);
- }
-
- // Stale handle (device dropped); discard it and reconnect.
- _connected.TryRemove(new KeyValuePair(deviceId, already));
- already.Dispose();
- }
-
if (!_discovered.TryGetValue(deviceId, out var info))
{
throw new InvalidOperationException(
$"Unknown device_id '{deviceId}'. Call discover_devices first and use a device_id from its result.");
}
- var device = await DaqifiDeviceFactory
- .ConnectFromDeviceInfoAsync(info, null, cancellationToken)
+ // The registry prunes stale handles (device dropped since it was registered) before
+ // the duplicate check, so a dropped device reconnects instead of returning a dead one.
+ var result = await _registry
+ .ConnectAsync(info, deviceId, options: null, cancellationToken)
.ConfigureAwait(false);
- _connected[deviceId] = device;
- return ConnectedDeviceInfo.From(deviceId, device);
+ if (result.Registration is null)
+ {
+ throw new InvalidOperationException(
+ $"Connection to '{deviceId}' was canceled by the duplicate-device policy.");
+ }
+
+ return ConnectedDeviceInfo.From(result.Registration.Key, result.Registration.Device);
}
finally
{
@@ -102,13 +107,10 @@ public async Task DisconnectAsync(string deviceId)
await _gate.WaitAsync().ConfigureAwait(false);
try
{
- if (_connected.TryRemove(deviceId, out var device))
- {
- try { device.Disconnect(); } catch { /* best effort */ }
- device.Dispose();
- return $"Disconnected '{deviceId}'.";
- }
- return $"Device '{deviceId}' was not connected.";
+ // Remove owns the teardown: disconnect then dispose.
+ return _registry.Remove(deviceId)
+ ? $"Disconnected '{deviceId}'."
+ : $"Device '{deviceId}' was not connected.";
}
finally
{
@@ -117,7 +119,7 @@ public async Task DisconnectAsync(string deviceId)
}
public IReadOnlyList ListConnected() =>
- _connected.Select(kvp => ConnectedDeviceInfo.From(kvp.Key, kvp.Value)).ToList();
+ _registry.Devices.Select(r => ConnectedDeviceInfo.From(r.Key, r.Device)).ToList();
// ----------------------------------------------------------- introspection
@@ -419,21 +421,20 @@ public async Task ShutdownAsync()
await _gate.WaitAsync().ConfigureAwait(false);
try
{
- foreach (var device in _connected.Values)
+ foreach (var registration in _registry.Devices)
{
try
{
- if (device is ISdCardOperations { IsLoggingToSdCard: true } sd)
+ if (registration.Device is ISdCardOperations { IsLoggingToSdCard: true } sd)
{
await sd.StopSdCardLoggingAsync().ConfigureAwait(false);
}
}
catch { /* best effort */ }
-
- try { device.Disconnect(); } catch { /* best effort */ }
- device.Dispose();
}
- _connected.Clear();
+
+ // Clear disconnects and disposes every registered device.
+ _registry.Clear();
}
finally
{
@@ -445,19 +446,19 @@ public async Task ShutdownAsync()
private DaqifiDevice Require(string deviceId)
{
- if (!_connected.TryGetValue(deviceId, out var device))
+ if (!_registry.TryGet(deviceId, out var registration))
{
throw new InvalidOperationException(
$"Device '{deviceId}' is not connected. Call connect_device first.");
}
- if (!device.IsConnected)
+ if (!registration!.Device.IsConnected)
{
- // Only evict the exact stale instance we inspected (a concurrent reconnect may have
- // already replaced it with a live one under the same id).
- _connected.TryRemove(new KeyValuePair(deviceId, device));
+ // Evict by reference so only the exact stale instance we inspected is removed: a
+ // concurrent reconnect may already have replaced it with a live one under the same id.
+ _registry.Remove(registration.Device);
throw new InvalidOperationException($"Device '{deviceId}' is no longer connected.");
}
- return device;
+ return registration.Device;
}
private (DaqifiDevice device, IStreamingDevice streaming) RequireStreaming(string deviceId)
diff --git a/src/Daqifi.Mcp/Tools/DaqifiTools.cs b/src/Daqifi.Mcp/Tools/DaqifiTools.cs
index 7cdf4b48..c0874683 100644
--- a/src/Daqifi.Mcp/Tools/DaqifiTools.cs
+++ b/src/Daqifi.Mcp/Tools/DaqifiTools.cs
@@ -25,7 +25,7 @@ public static Task> DiscoverDevices(
=> GuardAsync(() => agent.DiscoverAsync(timeoutMs, wifi, serial, cancellationToken));
[McpServerTool(Name = "connect_device")]
- [Description("Connect to a previously-discovered device. Pass a device_id from discover_devices. Channels are populated on connect.")]
+ [Description("Connect to a previously-discovered device. Pass a device_id from discover_devices. Channels are populated on connect. If that physical device is already connected (including over a different transport), the existing connection is returned — use the device_id from the result for follow-up calls.")]
public static Task ConnectDevice(
DaqifiAgent agent,
[Description("The device_id from discover_devices.")] string deviceId,