Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor comment formatting: "Apple TV /HomePods" is missing a space after the slash; consider "Apple TV / HomePods" for readability.

Suggested change
// Apple TV /HomePods (note: Apple devices can be many things)
// Apple TV / HomePods (note: Apple devices can be many things)

Copilot uses AI. Check for mistakes.
{ "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) },
Expand Down
63 changes: 59 additions & 4 deletions src/NetworkOptimizer.Audit/Services/DeviceTypeDetectionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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);
Comment on lines +451 to +453
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the Apple UniFi-OUI handling, CreateOuiResult is called with "Apple TV" / "Apple HomePod" as the vendor argument. That value is also written into Metadata["oui_name"], so the metadata no longer reflects the actual UniFi manufacturer string (e.g., "Apple, Inc."). Consider keeping the original ouiName in the metadata (and/or splitting manufacturer vs product labeling).

Suggested change
return CreateOuiResult(ClientDeviceCategory.StreamingDevice, "Apple TV", OuiHighConfidence);
if (deviceNameLower.Contains("homepod") || deviceNameLower.Contains("siri"))
return CreateOuiResult(ClientDeviceCategory.SmartSpeaker, "Apple HomePod", OuiHighConfidence);
{
return new DeviceDetectionResult
{
Category = ClientDeviceCategory.StreamingDevice,
Source = DetectionSource.MacOui,
ConfidenceScore = OuiHighConfidence,
VendorName = "Apple TV",
RecommendedNetwork = FingerprintDetector.GetRecommendedNetwork(ClientDeviceCategory.StreamingDevice),
Metadata = new Dictionary<string, object>
{
["detection_method"] = "unifi_oui_name",
["oui_name"] = ouiName
}
};
}
if (deviceNameLower.Contains("homepod") || deviceNameLower.Contains("siri"))
{
return new DeviceDetectionResult
{
Category = ClientDeviceCategory.SmartSpeaker,
Source = DetectionSource.MacOui,
ConfidenceScore = OuiHighConfidence,
VendorName = "Apple HomePod",
RecommendedNetwork = FingerprintDetector.GetRecommendedNetwork(ClientDeviceCategory.SmartSpeaker),
Metadata = new Dictionary<string, object>
{
["detection_method"] = "unifi_oui_name",
["oui_name"] = ouiName
}
};
}

Copilot uses AI. Check for mistakes.
}

return DeviceDetectionResult.Unknown;
}
Expand Down Expand Up @@ -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");
}

/// <summary>
Expand All @@ -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.
/// </summary>
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<string, object>
{
["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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<UniFiClientDetailResponse>
{
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
Expand Down