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:
- Is this a bug, or is the documentation overstating the persistence guarantee for USB video devices?
- 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)")
}