AVCaptureDevice.uniqueID for UVC devices is unstable - bug or overstated documentation?

The documentation for AVCaptureDevice.uniqueID states the following:

Capture devices have a unique identifier that persists on one system across device connections and disconnections, application restarts, and reboots of the system itself. You can store the value returned by this property to recall or track the status of a specific device in the future.

For UVC capture devices this documentation does not hold. The video uniqueID is a hex string of the form 0x<locationID><vendorID><productID>, and the identifying half is the locationID (bus number plus port path). Which identifies a port, not a device.

I ran a suite of tests with three identical Elgato 4K X capture cards connected to a Mac Studio w/ M3 Ultra running macOS 26.5.2, and reproduced my findings on a MacBook w/ M3 Pro (same macOS version). See the script at the bottom of the post for how uniqueId & USB serial number are being retrieved.

1. The uniqueID follows the port. Swapping two cards between two built-in ports swaps their uniqueIDs:

# Before swap.
4K X   uid=0x2000000fd9009b    serial=A7SNB50424UBQI
4K X   uid=0x12000000fd9009b   serial=A7SNB504219J0R

# After swapping the cards between the same two ports.
4K X   uid=0x2000000fd9009b    serial=A7SNB504219J0R
4K X   uid=0x12000000fd9009b   serial=A7SNB50424UBQI

An app that stored 0x2000000fd9009b to recall a specific capture card now silently opens another.

2. A reboot alone can swap uniqueIDs. External USB controllers (here, PCIe USB cards in two Thunderbolt enclosures) can race for bus numbers at boot, so with every cable left in place, a reboot swapped two of the cards:

# Before reboot.
4K X   uid=0x262000000fd9009b  serial=A7SNB504219J0R
4K X   uid=0x252000000fd9009b  serial=A7SNB50423R73R

# After reboot, no cables touched.
4K X   uid=0x262000000fd9009b  serial=A7SNB50423R73R
4K X   uid=0x252000000fd9009b  serial=A7SNB504219J0R

This behavior is intermittent, a second reboot changed nothing, but a third caused another swap. Cards left alone in built-in ports retain their uniqueIDs across reboots in my testing; the failure requires dynamically enumerated external USB controllers.

3. Even the product ID tail can drift. One unit intermittently enumerates with idProduct 0x009c instead of 0x009b, same port (USB PCIe card in a Thunderbolt enclosure), cables untouched:

# Before reboot.
4K X   uid=0x222000000fd9009b  serial=A7SNB50424UBQI

# After reboot.
4K X   uid=0x222000000fd9009c  serial=A7SNB50424UBQI

IOKit and AVFoundation agree each boot... So the change is upstream of both? I'm uncertain where to place blame for this specific issue (UVC device or macOS).


Audio on the same physical units is unaffected. The audio uniqueID (AppleUSBAudioEngine:...:<serial>:...) embeds the USB serial and stayed stable through every test. So AVCaptureDevice can provide a stable per-device identifier, just not for UVC video devices.

Questions:

  1. Is this a bug, or is the documentation overstating the persistence guarantee for USB video devices?
  2. What is the supported way to identify a specific physical UVC video device across reboots and port changes? The USB serial number is stable and is what I've fallen back on via IOKit, but there is no documented AVFoundation API to retrieve USB serial number from a UVC video AVCaptureDevice.

Related: thread 803759, where the locationID-derived format is described.

Script used for all output above (swift ./list-uvc.swift):

import AVFoundation
import IOKit

func usbSerial(forLocation location: UInt32) -> String? {
    var iterator: io_iterator_t = 0
    guard IOServiceGetMatchingServices(kIOMainPortDefault,
              IOServiceMatching("IOUSBHostDevice"), &iterator) == KERN_SUCCESS else { return nil }
    defer { IOObjectRelease(iterator) }

    var result: String?
    var service = IOIteratorNext(iterator)
    while service != 0 {
        var loc: UInt32 = 0
        if let ref = IORegistryEntryCreateCFProperty(service, "locationID" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue(),
           let num = ref as? NSNumber {
            loc = num.uint32Value
        }
        if loc == location,
           let ref = IORegistryEntryCreateCFProperty(service, "USB Serial Number" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue(),
           let serial = ref as? String {
            result = serial
        }
        IOObjectRelease(service)
        if result != nil { break }
        service = IOIteratorNext(iterator)
    }
    return result
}

let session = AVCaptureDevice.DiscoverySession(deviceTypes: [.external],
                                               mediaType: .video,
                                               position: .unspecified)

for device in session.devices {
    let uid = device.uniqueID
    let location = UInt32(truncatingIfNeeded: strtoull(uid, nil, 16) >> 32)
    let serial = usbSerial(forLocation: location) ?? "N/A"
    print("\(device.localizedName)  uid=\(uid)  serial=\(serial)")
}

Please file a bug about this (mine, FB12782458, is two years old).

In the meantime, you can synthesize your own persistent identifier which survives topology changes from vid, pid and serial number.

(As a quick side note, I'd recommend avoiding "Comment" and just replying using a normal post. Among other issues, comments don't notify the same way posts do, which makes it easy for me to miss that you've posted.)

Anyway, getting to specifics:

Per my example, adding PID is potentially problematic. In my testing, it's not reliable, as noted in the original post.

I don't know what's going on with the hardware you're testing with, but this:

  1. Even the product ID tail can drift. One unit intermittently enumerates with idProduct 0x009c instead of 0x009b, same port (USB PCIe card in a Thunderbolt enclosure), cables untouched:

...is pretty weird. That's literally the "Product ID", so having it change directly implies that something is an entirely "different" device.

IOKit and AVFoundation agree each boot... So the change is upstream of both?

It's possible that a driver (KEXT/DEXT) is modifying the PID at load, as this is one of the common modifications codeless KEXTs/DEXTs make to "tune" device behavior. If a codeless DEXT was involved, that also might explain the intermittent behavior— DEXTs can't actually load "at boot", which means you can get different drivers loading if the hardware is attached and boot vs. hot plugged. Note that I don't think our drivers did this, so this would involve a 3rd party driver, even if our driver is what ultimately ended up controlling the device*.

*Codeless KEXTs/DEXTs work by injecting themselves into the earliest part of "probe", modifying the device’s configuration in the I/O registry, then failing probe so that they never actually match against their target.

Is this a bug, or is the documentation overstating the persistence guarantee for USB video devices?

A bit of both? AVCaptureDevice could probably do "more" to try and ensure uniqueness; however, the problem is that this:

What is the supported way to identify a specific physical UVC video device across reboots and port changes?

...isn't ACTUALLY possible. More specifically:

The USB serial number is stable and is what I've fallen back on via IOKit.

That value is stable in the sense that IOKit is returning the data it got from the device (ignoring issues like the edge case above), so it shouldn't really change. However, the problem is that the USB spec doesn't actually require it to be meaningful/unique/useful. The values iManufacturer, iProduct, and iSerialNumber are all string values which the hardware can put anything they want into. Most products do use them "sensibly" (particularly iManufacturer and iProduct), but particularly cheap hardware often uses a fixed iSerialNumber, since it means you don't need to customize the firmware for every device you make.

Though, if VID is unchanging, it wouldn't be a terrible idea to append that to serial.

This VID is "stable", in that it’s specifically assigned and managed by USB-IF, so using someone else’s VID would be "wrong". Most commercial developers buy a VID from the USB-IF, at which point they then decide how they'll assign PIDs to their own products. However, smaller volume products sometimes use the VID of someone in their supply chain (chipset vendor, hardware assembler, etc.) and that vendor also assigns them a PID, so that their product doesn't overlap with someone else.

Shifting to here:

  1. A reboot alone can swap uniqueIDs. External USB controllers (here, PCIe USB cards in two Thunderbolt enclosures) can race for bus numbers at boot, so with every cable left in place, a reboot swapped two of the cards:

Ironically, this is actually a good example of what makes this so messy, as I think the reason bus location was factored into this was actually to make the uniqueID MORE stable when dealing with cheap hardware. AVCaptureDevice was introduced in macOS 10.7, which shipped in July 2011, just a few months after the first Thunderbolt Mac shipped in February of 2011.

Before Thunderbolt, the scenario you’re describing basically can't/doesn't happen. Even if you add multiple PCI USB cards (into PCI slots), the order everything initializes in will be fixed, which ends up stabilizing cheap devices we couldn't uniquely identify. More to the point, that's the same mechanism that stabilizes the ID if/when you plug multiple devices into the Mac’s USB ports.

None of that changes the issue you're dealing with, but hopefully that background context clarifies things.

I've already submitted one (FB23524998), apologies I should have included that in my post.

Thank you for that. I don't know if (or when) anything will change here, but I will discuss the issue with the team and let you know if they have anything else to share.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

AVCaptureDevice.uniqueID for UVC devices is unstable - bug or overstated documentation?
 
 
Q