diff --git a/src/NetworkOptimizer.Audit/Services/Detectors/MacOuiDetector.cs b/src/NetworkOptimizer.Audit/Services/Detectors/MacOuiDetector.cs index b803aa67e..e053b4b67 100644 --- a/src/NetworkOptimizer.Audit/Services/Detectors/MacOuiDetector.cs +++ b/src/NetworkOptimizer.Audit/Services/Detectors/MacOuiDetector.cs @@ -74,10 +74,16 @@ public class MacOuiDetector { "B8:3E:59", ("Roku", ClientDeviceCategory.StreamingDevice, 90) }, { "C8:3A:6B", ("Roku", ClientDeviceCategory.StreamingDevice, 90) }, - // Apple TV (note: Apple devices can be many things) + // Apple TV /HomePods (note: Apple devices can be many things) { "40:CB:C0", ("Apple TV", ClientDeviceCategory.StreamingDevice, 75) }, { "70:56:81", ("Apple TV", ClientDeviceCategory.StreamingDevice, 75) }, { "68:D9:3C", ("Apple TV", ClientDeviceCategory.StreamingDevice, 75) }, + { "A8:51:AB", ("Apple TV", ClientDeviceCategory.StreamingDevice, 75) }, + { "C8:D0:83", ("Apple TV", ClientDeviceCategory.StreamingDevice, 75) }, + { "9C:3E:53", ("Apple TV", ClientDeviceCategory.StreamingDevice, 75) }, + { "E0:2B:96", ("Apple HomePod", ClientDeviceCategory.SmartSpeaker, 75) }, + { "F4:34:F0", ("Apple HomePod", ClientDeviceCategory.SmartSpeaker, 75) }, + { "D4:90:9C", ("Apple HomePod", ClientDeviceCategory.SmartSpeaker, 75) }, // Chromecast { "54:60:09", ("Chromecast", ClientDeviceCategory.StreamingDevice, 85) }, diff --git a/src/NetworkOptimizer.Audit/Services/DeviceTypeDetectionService.cs b/src/NetworkOptimizer.Audit/Services/DeviceTypeDetectionService.cs index 7516445f5..932f1f64f 100644 --- a/src/NetworkOptimizer.Audit/Services/DeviceTypeDetectionService.cs +++ b/src/NetworkOptimizer.Audit/Services/DeviceTypeDetectionService.cs @@ -176,7 +176,8 @@ private DeviceDetectionResult DetectDeviceTypeCore( // Priority 0.5: Check OUI for vendors that need special handling // - Cync/Wyze/GE have camera fingerprints but most devices are actually plugs/bulbs // - Apple with SmartSensor fingerprint is likely Apple Watch - var vendorOverrideResult = CheckVendorDefaultOverride(client?.Oui, client?.Name, client?.Hostname, client?.DevCat); + // - Apple with generic fingerprints (SmartTV, IoTGeneric) should use MAC OUI for specific device type + var vendorOverrideResult = CheckVendorDefaultOverride(client?.Oui, client?.Name, client?.Hostname, client?.DevCat, client?.Mac); if (vendorOverrideResult != null) { _logger?.LogDebug("[Detection] '{DisplayName}': Vendor override → {Category} (vendor defaults to plug unless camera indicated)", @@ -442,7 +443,15 @@ private DeviceDetectionResult DetectFromUniFiOui(string ouiName, string? deviceN // Media/Entertainment if (name.Contains("roku")) return CreateOuiResult(ClientDeviceCategory.StreamingDevice, ouiName, OuiHighConfidence); - if (name.Contains("apple") && name.Contains("tv")) return CreateOuiResult(ClientDeviceCategory.StreamingDevice, ouiName, OuiHighConfidence); + + // Apple devices: Use device name to disambiguate between Apple TV and HomePod + if (name.Contains("apple")) + { + if (deviceNameLower.Contains("tv") || deviceNameLower.Contains("apple tv")) + return CreateOuiResult(ClientDeviceCategory.StreamingDevice, "Apple TV", OuiHighConfidence); + if (deviceNameLower.Contains("homepod") || deviceNameLower.Contains("siri")) + return CreateOuiResult(ClientDeviceCategory.SmartSpeaker, "Apple HomePod", OuiHighConfidence); + } return DeviceDetectionResult.Unknown; } @@ -1185,7 +1194,8 @@ private static bool IsCloudSecurityVendor(string vendorLower) System.Text.RegularExpressions.Regex.IsMatch(vendorLower, @"\barlo\b") || System.Text.RegularExpressions.Regex.IsMatch(vendorLower, @"\bsimplisafe\b") || System.Text.RegularExpressions.Regex.IsMatch(vendorLower, @"\btp-link\b") || - System.Text.RegularExpressions.Regex.IsMatch(vendorLower, @"\bcanary\b"); + System.Text.RegularExpressions.Regex.IsMatch(vendorLower, @"\bcanary\b") || + System.Text.RegularExpressions.Regex.IsMatch(vendorLower, @"\bfurbo\b"); } /// @@ -1203,11 +1213,56 @@ private static bool IsThermostatName(string nameLower) /// - Apple devices with SmartSensor fingerprint are usually Apple Watches (Smartphone). /// - GoPro action cameras share devCat 106 with security cameras but aren't security devices. /// - private DeviceDetectionResult? CheckVendorDefaultOverride(string? oui, string? name, string? hostname, int? devCat) + private DeviceDetectionResult? CheckVendorDefaultOverride(string? oui, string? name, string? hostname, int? devCat, string? mac) { var ouiLower = oui?.ToLowerInvariant() ?? ""; var nameLower = (name ?? hostname ?? "").ToLowerInvariant(); + // Apple devices with generic fingerprints should check MAC OUI for specific device type + // Apple controls their hardware tightly, so MAC OUI is highly reliable for Apple devices + // This catches Apple TVs (SmartTV fingerprint) and HomePods (IoTGeneric) even without specific names + if (ouiLower.Contains("apple") && !string.IsNullOrEmpty(mac)) + { + var isGenericFingerprint = devCat == 51 || // IoTGeneric + devCat == 7 || // SmartTV (generic) + devCat == 47; // SmartTV (alternative) + + if (isGenericFingerprint) + { + _logger?.LogDebug("[VendorOverride] Apple device with generic fingerprint detected: OUI='{Oui}', DevCat={DevCat}, MAC={Mac}", + oui, devCat, mac); + + var macOuiResult = _macOuiDetector.Detect(mac); + if (macOuiResult.Category != ClientDeviceCategory.Unknown) + { + _logger?.LogDebug("[VendorOverride] MAC OUI lookup successful: {MacPrefix} → {Category} ({VendorName})", + mac.Substring(0, Math.Min(8, mac.Length)), macOuiResult.Category, macOuiResult.VendorName); + + // MAC OUI database has a specific match for this Apple device + return new DeviceDetectionResult + { + Category = macOuiResult.Category, + Source = DetectionSource.MacOui, + ConfidenceScore = 98, // Very high confidence - Apple OUI + specific device match + VendorName = macOuiResult.VendorName, + RecommendedNetwork = macOuiResult.RecommendedNetwork, + Metadata = new Dictionary + { + ["override_reason"] = "Apple device with generic fingerprint - MAC OUI provides specific device type", + ["oui"] = oui ?? "", + ["dev_cat"] = devCat ?? 0, + ["mac_oui_category"] = macOuiResult.Category.ToString() + } + }; + } + else + { + _logger?.LogDebug("[VendorOverride] MAC OUI lookup found no match for {MacPrefix} - falling back to fingerprint", + mac.Substring(0, Math.Min(8, mac.Length))); + } + } + } + // Apple devices with SmartSensor fingerprint (DevCat=14) are likely Apple Watches if (ouiLower.Contains("apple") && devCat == 14) { diff --git a/tests/NetworkOptimizer.Audit.Tests/Services/DeviceTypeDetectionServiceTests.cs b/tests/NetworkOptimizer.Audit.Tests/Services/DeviceTypeDetectionServiceTests.cs index 45e0f3d66..e42cc9df2 100644 --- a/tests/NetworkOptimizer.Audit.Tests/Services/DeviceTypeDetectionServiceTests.cs +++ b/tests/NetworkOptimizer.Audit.Tests/Services/DeviceTypeDetectionServiceTests.cs @@ -228,6 +228,152 @@ public void DetectDeviceType_RingVendor_ReturnsCloudCamera() result.Category.Should().Be(ClientDeviceCategory.CloudCamera); } + [Fact] + public void DetectDeviceType_FurboVendor_ReturnsCloudCamera() + { + // Arrange - Furbo is a cloud camera vendor (dog camera with treat tossing) + var client = new UniFiClientResponse + { + Mac = "11:22:33:44:55:66", + Name = "Furbo Dog Camera", + Oui = "Furbo", + DevCat = 9 // Camera fingerprint (9 = IP Network Camera) + }; + + // Act + var result = _service.DetectDeviceType(client); + + // Assert - Furbo cameras are cloud cameras (require internet for remote access) + result.Category.Should().Be(ClientDeviceCategory.CloudCamera); + } + + #endregion + + #region Apple Device Vendor Override Tests (Generic Fingerprints) + + [Theory] + [InlineData("9C:3E:53:2A:72:5A", "Keeping-Room 72:5a", 47)] // SmartTV fingerprint, Apple TV OUI + [InlineData("C8:D0:83:B9:2B:A0", "Living-Room-Wireless", 7)] // SmartTV fingerprint, Apple TV OUI + [InlineData("A8:51:AB:13:F0:CD", "Guest-Media", 47)] // SmartTV fingerprint, Apple TV OUI (avoid "appletv" in name) + [InlineData("68:D9:3C:11:22:33", "Theater-Device", 47)] // SmartTV fingerprint, Apple TV OUI + public void DetectDeviceType_AppleOuiWithSmartTVFingerprint_UsesOuiForStreamingDevice(string mac, string deviceName, int devCat) + { + // Arrange - Apple devices with generic SmartTV fingerprint should use MAC OUI + // to get specific device type (Apple TV = StreamingDevice, not generic SmartTV) + var client = new UniFiClientResponse + { + Mac = mac, + Name = deviceName, + Oui = "Apple, Inc.", + DevCat = devCat // SmartTV fingerprint (generic) + }; + + // Act + var result = _service.DetectDeviceType(client); + + // Assert - Should detect as StreamingDevice (from MAC OUI), not SmartTV (from fingerprint) + result.Category.Should().Be(ClientDeviceCategory.StreamingDevice); + result.VendorName.Should().Be("Apple TV"); + result.ConfidenceScore.Should().Be(98); // High confidence - Apple OUI + specific MAC match + result.Source.Should().Be(DetectionSource.MacOui); + } + + [Theory] + [InlineData("E0:2B:96:9C:03:1E", "Keeping-Room-Speaker", 51)] // IoTGeneric fingerprint, HomePod OUI (avoid "siri" in name) + [InlineData("F4:34:F0:3E:69:C2", "Guest-Speaker", 51)] // IoTGeneric fingerprint, HomePod OUI + public void DetectDeviceType_AppleOuiWithIoTGenericFingerprint_UsesOuiForSmartSpeaker(string mac, string deviceName, int devCat) + { + // Arrange - Apple devices with generic IoTGeneric fingerprint should use MAC OUI + // to get specific device type (HomePod = SmartSpeaker, not generic IoT) + var client = new UniFiClientResponse + { + Mac = mac, + Name = deviceName, + Oui = "Apple, Inc.", + DevCat = devCat // IoTGeneric fingerprint (generic) + }; + + // Act + var result = _service.DetectDeviceType(client); + + // Assert - Should detect as SmartSpeaker (from MAC OUI), not IoTGeneric (from fingerprint) + result.Category.Should().Be(ClientDeviceCategory.SmartSpeaker); + result.VendorName.Should().Be("Apple HomePod"); + result.ConfidenceScore.Should().Be(98); // High confidence - Apple OUI + specific MAC match + result.Source.Should().Be(DetectionSource.MacOui); + } + + [Fact] + public void DetectDeviceType_AppleOuiWithGenericFingerprintButNoMacOuiMatch_UsesFingerprint() + { + // Arrange - Apple device with generic fingerprint but MAC prefix not in our OUI database + // Should fall back to normal fingerprint detection + var client = new UniFiClientResponse + { + Mac = "AA:BB:CC:DD:EE:FF", // Unknown MAC prefix + Name = "Some-Device", + Oui = "Apple, Inc.", + DevCat = 47 // SmartTV fingerprint + }; + + // Act + var result = _service.DetectDeviceType(client); + + // Assert - No specific MAC OUI match, so should use fingerprint (SmartTV) + // This might be a generic Apple-compatible TV or other device + result.Category.Should().Be(ClientDeviceCategory.SmartTV); + result.ConfidenceScore.Should().Be(95); // Normal fingerprint confidence + } + + [Theory] + [InlineData("E0:2B:96:9C:03:1E", "Office Siri", 51)] // Name-based detection takes priority + [InlineData("F4:34:F0:3E:69:C2", "Master HomePod", 51)] // Name-based detection takes priority + public void DetectDeviceType_AppleHomePodWithSiriOrHomePodInName_StillDetectsCorrectly(string mac, string deviceName, int devCat) + { + // Arrange - HomePods with "siri" or "homepod" in name should be detected + // through either name-based override OR MAC OUI override (both work) + var client = new UniFiClientResponse + { + Mac = mac, + Name = deviceName, + Oui = "Apple, Inc.", + DevCat = devCat // IoTGeneric fingerprint + }; + + // Act + var result = _service.DetectDeviceType(client); + + // Assert + result.Category.Should().Be(ClientDeviceCategory.SmartSpeaker); + result.ConfidenceScore.Should().BeGreaterThanOrEqualTo(95); // High confidence (95% from name, or 98% from vendor override) + } + + [Theory] + [InlineData(1)] // Laptop + [InlineData(2)] // Tablet + [InlineData(4)] // Smartphone + [InlineData(117)] // Desktop + public void DetectDeviceType_AppleOuiWithSpecificFingerprint_DoesNotOverride(int devCat) + { + // Arrange - Apple devices with specific (non-generic) fingerprints should NOT be overridden + // Only generic categories (SmartTV, IoTGeneric) trigger the MAC OUI override + var client = new UniFiClientResponse + { + Mac = "E0:2B:96:9C:03:1E", // HomePod OUI + Name = "Device", + Oui = "Apple, Inc.", + DevCat = devCat // Specific fingerprint + }; + + // Act + var result = _service.DetectDeviceType(client); + + // Assert - Should use the specific fingerprint, not override to HomePod based on MAC + // (Even though MAC says HomePod, the specific fingerprint is more trustworthy for what's actually connected) + result.Category.Should().NotBe(ClientDeviceCategory.SmartSpeaker); + result.ConfidenceScore.Should().Be(95); // Normal fingerprint confidence + } + #endregion #region Camera Name Override Tests (Nest/Google cameras) @@ -258,6 +404,29 @@ public void DetectDeviceType_NestOrGoogleWithCameraName_ReturnsCloudCamera(strin result.Category.Should().Be(ClientDeviceCategory.CloudCamera); } + [Theory] + [InlineData("Furbo", "Living Room")] + [InlineData("FURBO", "Dog Camera")] + [InlineData("Furbo Inc", "Pet Camera")] + [InlineData("Furbo Dog Camera", "Kitchen Cam")] + public void DetectDeviceType_FurboVendorVariations_ReturnsCloudCamera(string vendor, string deviceName) + { + // Arrange - Test various Furbo vendor name formats (case-insensitive, with suffixes) + var client = new UniFiClientResponse + { + Mac = "11:22:33:44:55:66", + Name = deviceName, + Oui = vendor, + DevCat = 9 // Camera fingerprint + }; + + // Act + var result = _service.DetectDeviceType(client); + + // Assert - All Furbo variations should be detected as cloud cameras + result.Category.Should().Be(ClientDeviceCategory.CloudCamera); + } + [Theory] [InlineData("Nest Labs Inc.", "Living Room Thermostat", ClientDeviceCategory.SmartThermostat)] [InlineData("Nest Labs Inc.", "Hallway Ecobee", ClientDeviceCategory.SmartThermostat)] @@ -976,6 +1145,7 @@ public void SetClientHistory_FiltersEntriesWithEmptyMac() [InlineData("Blink", ClientDeviceCategory.CloudCamera)] [InlineData("TP-Link", ClientDeviceCategory.CloudCamera)] [InlineData("Canary", ClientDeviceCategory.CloudCamera)] + [InlineData("Furbo", ClientDeviceCategory.CloudCamera)] public void DetectFromMac_HistoryCameraFingerprint_WithCloudVendor_ReturnsCloudCamera(string vendor, ClientDeviceCategory expected) { // Arrange - history with camera fingerprint (DevCat 9) and cloud vendor via OUI @@ -1157,6 +1327,36 @@ public void DetectDeviceType_GoogleNestSpeakers_SetsGoogleVendor(string deviceNa result.VendorName.Should().Be("Google"); } + [Theory] + [InlineData("E0:2B:96:12:34:56", "Office Siri")] + [InlineData("F4:34:F0:AB:CD:EF", "Guest Room 1 Siri")] + [InlineData("D4:90:9C:11:22:33", "Living Room Siri")] + [InlineData("E0:2B:96:44:55:66", "Game Room Speaker 1")] + [InlineData("F4:34:F0:77:88:99", "Game Room Speaker 2")] + public void DetectFromMac_AppleSpeakerOui_ReturnsSmartSpeaker(string macAddress, string deviceName) + { + // Arrange - Test MAC OUI detection for Apple smart speakers using real device names + // These OUIs are specific to Apple's smart speaker product line + var history = new List + { + new() + { + Mac = macAddress, + Name = deviceName + } + }; + _service.SetClientHistory(history); + + // Act + var result = _service.DetectFromMac(macAddress); + + // Assert - Should detect as SmartSpeaker via MAC OUI + result.Category.Should().Be(ClientDeviceCategory.SmartSpeaker); + result.VendorName.Should().Be("Apple HomePod"); + result.Source.Should().Be(DetectionSource.MacOui); + result.ConfidenceScore.Should().Be(75); + } + #endregion #region Vendor Preservation Tests - VR Headsets