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,