







|
Introduction
Most flash-based USB disk devices have a unique serial number assigned
by the manufacturer. (However, some earlier v1.1-based USB devices may
not support a serial number)
There are generally two different methods for getting the serial number of a
USB-based device... an "easy" way using Windows Management Instrumentation (WMI)
and a "hard" way using the Win32 APIs. There are advantages and
disadvantages for both methods... one is slow but simple to implement, the other
is fast (and potentially provides more information) but is difficult to
implement.
Method 1: Using Windows Management Instrumentation (WMI)
This might be a good time to review a companion article on
Introduction to Windows Management Instrumentation.
The WMI technique uses a series of "relationships" that exist between several
WMI classes. We start with the Win32_LogicalDisk class, track it to
the Win32_DiskPartition class (which itself is just a relationship class), and
then track that to the WiNote: Although it is generally accepted as true, there is no documentation
to guarantee that the PnPDeviceID will continue to contain the Serial Number.
Public Function GetSerialNumber(ByVal DriveLetter As String) As String
Dim wmi_ld, wmi_dp, wmi_dd As ManagementObject
Dim temp, parts(), ans As String
ans = ""
' get the Logical Disk for that drive letter
wmi_ld = New ManagementObject("Win32_LogicalDisk.DeviceID='" & _
DriveLetter.TrimEnd("\"c) & "'")
' get the associated DiskPartition
For Each wmi_dp In wmi_ld.GetRelated("Win32_DiskPartition")
' get the associated DiskDrive
For Each wmi_dd In wmi_dp.GetRelated("Win32_DiskDrive")
' the serial number is embedded in the PnPDeviceID
temp = wmi_dd("PnPDeviceID").ToString
If Not temp.StartsWith("USBSTOR") Then
Throw New ApplicationException(DriveLetter & " doesn't appear to be USB Device")
End If
parts = temp.Split("\&".ToCharArray)
' The serial number should be the next to the last element
ans = parts(parts.Length - 2)
Next
Next
Return ans
End Functionl number should be the next to the last element
ans = parts(parts.Length - 2)
Next
Next
Return ans
End Function
Note: There is a bug in the Windows Vista "provider" for the Win32_Disk
class... and the above technique won't work on Vista. So, use the
following as a work around:
Public Function GetSerialNumber(ByVal DriveLetter As String) As String
Dim wmi_ld, wmi_dp, wmi_dd As ManagementObject
Dim temp, parts(), ans As String
ans = ""
' get the Logical Disk for that drive letter
wmi_ld = New ManagementObject("Win32_LogicalDisk.DeviceID='" & _
DriveLetter.TrimEnd("\"c) & "'")
' get the associated DiskPartition
For Each wmi_dp In wmi_ld.GetRelated("Win32_DiskPartition")
' get the associated DiskDrive
For Each wmi_dd In wmi_dp.GetRelated("Win32_DiskDrive")
' There is a bug in WinVista that corrupts some of the fields
' of the Win32_DiskDrive class if you instantiate the class via
' its primary key (as in the example above) and the device is
' a USB disk. Oh well... so we have go thru this extra step
Dim wmi As New ManagementClass("Win32_DiskDrive")
' loop thru all of the instances. This is silly, we shouldn't
' have to loop thru them all, when we know which one we want.
For Each obj As ManagementObject In wmi.GetInstances
' do the DeviceID fields match?
If obj("DeviceID").ToString = wmi_dd("DeviceID").ToString Then
' the serial number is embedded in the PnPDeviceID
temp = obj("PnPDeviceID").ToString
If Not temp.StartsWith("USBSTOR") Then
Throw New ApplicationException(DriveLetter & " doesn't appear to be USB Device")
End If
parts = temp.Split("\&".ToCharArray)
' The serial number should be the next to the last element
ans = parts(parts.Length - 2)
End If
Next
Next
Next
Return ans
End Function
I think you need to look at the Win32 API method to truly appreciate the
simplicity of the WMI method.
Method 2: Using the Win32 APIs
OK, now let's do it the "hard" way. The API method uses
functions from SetupAPI.DLL for listing devices and getting device parameters.
It also uses the normal DeviceIoControl API function from Kernel.DLL to
talk to the hardware devices. I'll admit, the technique is a bit convoluted,
so let's break it down into steps:
- Step 1: Find the correct disk device in the "device tree" by searching
for a unique device number
- Step 2: Get the InstanceID of the device and "walk the device tree"
upwards to get the DevicePath of the Hub
- Step 3: Loop thru each of the ports on the Hub to find the matching
InstanceID
- Step 4: Get the iSerialNumber "index" from the DeviceDescriptor
- Step 5: Get the string value for StringDescriptor
Note: I have not included the API Consts, Enums, Structures, and Declares
in this web article (because they took up too much room). However, they
are in the downloadable sample code (see the link at the bottom of the article).
This top-level routine follows the steps outlined above. The
FindDiskDevice() function searches the device tree for DeviceNumber
that matches that of the drive letter. It returns (via it passed by
reference parameters) the full HubDevicePath for the USB Hub and the
unique InstanceID of the drive. The GetPortCount() function
merely returns the number of USB ports on the Hub. Next it uses the
GetDriverKeyName() and FindInstanceIDByKeyName() functions to find
the correct port number on the Hub. Next, the GetDeviceDescriptor()
method returns the DeviceDescriptor so we know the "index" of the Serial
Number string. And lastly, the GetStringDescriptor() function
returns the actual serial number string.
Function GetSerialNumber(ByVal DriveLetter As String) As String
Dim SerialNumber As String = ""
Dim InstanceID As String = ""
Dim HubDevicePath As String = ""
Dim HubPortCount As Integer
' HubDevicePath and InstanceID are passed by reference
If Not FindDiskDevice(DriveLetter, HubDevicePath, InstanceID) Then
Throw New ApplicationException("Can't find the device instance")
End If
If Not InstanceID.StartsWith("USB\") Then
Throw New ApplicationException("This drive doesn't appear to be a USB device")
End If
' how many ports are there?
HubPortCount = GetPortCount(HubDevicePath)
If HubPortCount = 0 Then
Throw New ApplicationException("Can't get the number of ports on the hub")
End If
' loop thru all of the ports hunting for a matching InstanceID
' BTW: Port numbers start at 1
For i As Integer = 1 To HubPortCount
' does the device match the InstanceID we're looking for?
If FindInstanceIDByKeyName(GetDriverKeyName(HubDevicePath, i)) = InstanceID Then
' get the "index" for the serial number
Dim DeviceDescriptor As USB_DEVICE_DESCRIPTOR = _
GetDeviceDescriptor(HubDevicePath, i)
' a iSerialNumber of 0 means there is no serial number
If DeviceDescriptor.iSerialNumber > 0 Then
SerialNumber = GetStringDescriptor(HubDevicePath, i, _
DeviceDescriptor.iSerialNumber)
Exit For
End If
End If
Next
Return SerialNumber
End Function
Every "Storage Device" is assigned a unique number based upon its device
type. We use this feature to allow us to see if two different device paths
(such as "\\.\E:" and "\\\\?\\usbstor#disk&ven_lexar&prod_jd_lightning&rev_3000#33000001928000002345&0#{53f56307-b6bf-11d0-94f2-00a0c91efb8b}")
are actually pointing to the same device. Since the
STORAGE_DEVICE_NUMBER.DeviceNumber field is
only unique with its STORAGE_DEVICE_NUMBER.DeviceType,
we "fold" the two numbers together.
Private Function GetDeviceNumber(ByVal DevicePath As String) As Integer
Dim ans As Integer = -1
Dim h As IntPtr = CreateFile(DevicePath.TrimEnd("\"c), 0, 0, Nothing, _
OPEN_EXISTING, 0, Nothing)
If h.ToInt32 <> INVALID_HANDLE_VALUE Then
Dim reqSize As Integer = 0
Dim Sdn As New STORAGE_DEVICE_NUMBER
Dim nBytes As Integer = Marshal.SizeOf(Sdn)
Dim ptrSdn As IntPtr = Marshal.AllocHGlobal(nBytes)
If DeviceIoControl(h, IOCTL_STORAGE_GET_DEVICE_NUMBER, IntPtr.Zero, 0, _
ptrSdn, Marshal.SizeOf(Sdn), reqSize, Nothing) Then
Sdn = CType(Marshal.PtrToStructure(ptrSdn, GetType( _
STORAGE_DEVICE_NUMBER)), STORAGE_DEVICE_NUMBER)
' just my way of combining the relevant parts of the
' STORAGE_DEVICE_NUMBER into a single number
ans = (Sdn.DeviceType << 8) + Sdn.DeviceNumber
End If
Marshal.FreeHGlobal(ptrSdn)
CloseHandle(h)
End If
Return ans
End Function
Next, we need to find the full "symbolic name" of the USB Hub where are disk
is located (an average PC might have 2-3 internal USB hubs), and we also need the
"Instance ID" of the device itself (sorta like a device driver name). We
start by getting the DeviceNumber of the drive letter assigned to the USB disk.
Next, we search the entire "device tree" for a device that matches that device
number. After we found a match, we need to "walk the device tree" upwards
to get path to the USB Hub. These two strings are passed "by reference",
so that we can make changes to them inside this function.
Private Function FindDiskDevice(ByVal DriveLetter As String, ByRef HubDevicePath As _
String, ByRef InstanceID As String) As Boolean
Dim ans As Boolean = False
' Get the DeviceNumber using the drive letter (without a trailing
' backslash, ie "C:"). We'll use this later to match the DeviceNumber
' of each of the device's Symbolic Name
Dim DeviceNumber As Integer = GetDeviceNumber("\\.\" & DriveLetter.TrimEnd("\"c))
If DeviceNumber < 0 Then
Return ans
End If
Dim DiskGUID As New Guid(GUID_DEVINTERFACE_DISK)
' We start at the "root" of the device tree and look for all
' devices that match the interface GUID of a disk
Dim hSetup As IntPtr = SetupDiGetClassDevs(DiskGUID, 0, IntPtr.Zero, DIGCF_PRESENT _
Or DIGCF_DEVICEINTERFACE)
If hSetup.ToInt32 <> INVALID_HANDLE_VALUE Then
Dim Success As Boolean
Dim i As Integer = 0
do
' create a Device Interface Data structure
Dim dia As New SP_DEVICE_INTERFACE_DATA
dia.cbSize = Marshal.SizeOf(dia)
' start the enumeration
Success = SetupDiEnumDeviceInterfaces(hSetup, IntPtr.Zero, DiskGUID, i, dia)
If Success Then
' prepare a Devinfo Data structure
Dim da As New SP_DEVINFO_DATA
da.cbSize = Marshal.SizeOf(da)
' prepare a Device Interface Detail Data structure
Dim didd As New SP_DEVICE_INTERFACE_DETAIL_DATA
didd.cbSize = 4 + Marshal.SystemDefaultCharSize ' trust me :)
' now we can get some more detailed information
Dim nBytes As Integer = BUFFER_SIZE
Dim nRequiredSize As Integer = 0
If SetupDiGetDeviceInterfaceDetail(hSetup, dia, didd, nBytes, nRequiredSize, da) Then
' OK, let's get the Device Number again... this time using
' the device's "Symbolic Name". If the numbers match, we've
' found the one we're looking for.
If GetDeviceNumber(didd.DevicePath) = DeviceNumber Then
' This should get us to the USBSTOR "level"
Dim ptrPrevious As IntPtr
CM_Get_Parent(ptrPrevious, da.DevInst, 0)
' Get the InstanceID
Dim ptrBuf As IntPtr = Marshal.AllocHGlobal(nBytes)
CM_Get_Device_ID(ptrPrevious, ptrBuf, nBytes, 0)
InstanceID = Marshal.PtrToStringAuto(ptrBuf)
' This should get us to the USB "level" (the USB hub)
CM_Get_Parent(ptrPrevious, ptrPrevious, 0)
' Now get the ID of the hub
CM_Get_Device_ID(ptrPrevious, ptrBuf, nBytes, 0)
Dim temp As String = Marshal.PtrToStringAuto(ptrBuf)
Marshal.FreeHGlobal(ptrBuf)
' Build the final string that represents the full "Symbolic Name"
' of the USB Hub using its ID
HubDevicePath = "\\.\" & temp.Replace("\", "#") & "#{" & _
GUID_DEVINTERFACE_USB_HUB & "}"
ans = True
Exit Do
End If
End If
End If
i += 1
Loop While Success
End If
SetupDiDestroyDeviceInfoList(hSetup)
Return ans
End Function
Next we need to get the "Driver Key Name" of a device, given the full path to
the USB Hub and the port number on the Hub. The Driver Key Name isn't used
directly by this application, instead it's used as an intermediate value to
enable us to get the dvice's Instance ID.
In USB programming, you rarely talk to the USB
device directly... you talk to the Hub and ask the Hub to intercede on your behalf.
You must know the port number to where the device is located in order to get any
meaningful data from the USB Hub. If you don't know the port number,
you're forced to try them all to find the one you want.
Private Function GetDriverKeyName(ByVal HubPath As String, ByVal PortNumber _
As Integer) As String
Dim ans As String = ""
' open a handle to the USB hub
Dim hHub As IntPtr = CreateFile(HubPath, GENERIC_WRITE, FILE_SHARE_WRITE, _
IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero)
If hHub.ToInt32 <> INVALID_HANDLE_VALUE Then
Dim nBytesReturned As Integer
Dim nBytes As Integer = Marshal.SizeOf(GetType( _
USB_NODE_CONNECTION_INFORMATION_EX))
Dim ptrNodeConnection As IntPtr = Marshal.AllocHGlobal(nBytes)
Dim NodeConnection As New USB_NODE_CONNECTION_INFORMATION_EX
NodeConnection.ConnectionIndex = PortNumber
Marshal.StructureToPtr(NodeConnection, ptrNodeConnection, True)
' let's check to see if there is something plugged in first
If DeviceIoControl(hHub, IOCTL_USB_GET_NODE_CONNECTION_INFORMATION_EX, _
ptrNodeConnection, nBytes, ptrNodeConnection, nBytes, nBytesReturned, _
IntPtr.Zero) Then
NodeConnection = CType(Marshal.PtrToStructure(ptrNodeConnection, _
GetType(USB_NODE_CONNECTION_INFORMATION_EX)), _
USB_NODE_CONNECTION_INFORMATION_EX)
If NodeConnection.ConnectionStatus = _
USB_CONNECTION_STATUS.DeviceConnected Then
' now let's get the Driver Key Name
Dim DriverKey As New USB_NODE_CONNECTION_DRIVERKEY_NAME
DriverKey.ConnectionIndex = PortNumber
nBytes = Marshal.SizeOf(DriverKey)
Dim ptrDriverKey As IntPtr = Marshal.AllocHGlobal(nBytes)
Marshal.StructureToPtr(DriverKey, ptrDriverKey, True)
'Use an IOCTL call to request the Driver Key Name
If DeviceIoControl(hHub, IOCTL_USB_GET_NODE_CONNECTION_DRIVERKEY_NAME, _
ptrDriverKey, nBytes, ptrDriverKey, nBytes, nBytesReturned, IntPtr.Zero) Then
DriverKey = CType(Marshal.PtrToStructure(ptrDriverKey, GetType( _
USB_NODE_CONNECTION_DRIVERKEY_NAME)), _
USB_NODE_CONNECTION_DRIVERKEY_NAME)
ans = DriverKey.DriverKeyName
End If
Marshal.FreeHGlobal(ptrDriverKey)
End If
End If
' Clean up and go home
Marshal.FreeHGlobal(ptrNodeConnection)
CloseHandle(hHub)
End If
Return ans
End Function
This next section of code returns the number of ports on a given hub.
Private Function GetPortCount(ByVal HubDevicePath As String) As Integer
Dim ans As Integer = 0
' open a connection to the HubDevice (that we just found)
Dim hHub As IntPtr = CreateFile(HubDevicePath, GENERIC_WRITE, _
FILE_SHARE_WRITE, IntPtr.Zero, OPEN_EXISTING, 0, IntPtr.Zero)
If hHub.ToInt32 <> INVALID_HANDLE_VALUE Then
Dim nBytesReturned As Integer = 0
Dim NodeInfo As New USB_NODE_INFORMATION
NodeInfo.NodeType = USB_HUB_NODE.UsbHub
Dim nBytes As Integer = Marshal.SizeOf(NodeInfo)
Dim ptrNodeInfo As IntPtr = Marshal.AllocHGlobal(nBytes)
Marshal.StructureToPtr(NodeInfo, ptrNodeInfo, True)
' get the number of ports on the hub
If DeviceIoControl(hHub, IOCTL_USB_GET_NODE_INFORMATION, ptrNodeInfo, _
nBytes, ptrNodeInfo, nBytes, nBytesReturned, IntPtr.Zero) Then
NodeInfo = CType(Marshal.PtrToStructure(ptrNodeInfo, GetType( _
USB_NODE_INFORMATION)), USB_NODE_INFORMATION)
ans = NodeInfo.HubInformation.HubDescriptor.bNumberOfPorts
End If
Marshal.FreeHGlobal(ptrNodeInfo)
CloseHandle(hHub)
End If
Return ans
End Function
We need to be able to compare two USB Instance IDs to see if they point to
the same device. This is completely analogous to the technique we used to
compare storage device numbers. There is no straight-forward
technique for a converting a USB "Driver Key Name" into a "Instance ID".
So, we're forced to use the SetupAPI again to examine each device in the device
tree for a matching DriverKeyName, and when found, we use
SetupDiGetDeviceInstanceId() to return the associated InstanceID.
Private Function FindInstanceIDByKeyName(ByVal DriverKeyName As String) As String
Dim ans As String = ""
Dim DevEnum As String = REGSTR_KEY_USB
' Use the "enumerator form" of the SetupDiGetClassDevs API
' to generate a list of all USB devices
Dim h As IntPtr = SetupDiGetClassDevs(0, DevEnum, IntPtr.Zero, DIGCF_PRESENT _
Or DIGCF_ALLCLASSES)
If h.ToInt32 <> INVALID_HANDLE_VALUE Then
Dim ptrBuf As IntPtr = Marshal.AllocHGlobal(BUFFER_SIZE)
Dim KeyName As String
Dim Success As Boolean
Dim i As Integer = 0
Do
' create a Device Interface Data structure
Dim da As New SP_DEVINFO_DATA
da.cbSize = Marshal.SizeOf(da)
' start the enumeration
Success = SetupDiEnumDeviceInfo(h, i, da)
If Success Then
Dim RequiredSize As Integer = 0
Dim RegType As Integer = REG_SZ
KeyName = ""
' get the driver key name
If SetupDiGetDeviceRegistryProperty(h, da, SPDRP_DRIVER, RegType, ptrBuf, _
BUFFER_SIZE, RequiredSize) Then
KeyName = Marshal.PtrToStringAuto(ptrBuf)
End If
' do we have a match?
If KeyName = DriverKeyName Then
Dim nBytes As Integer = BUFFER_SIZE
Dim sb As New StringBuilder(nBytes)
SetupDiGetDeviceInstanceId(h, da, sb, nBytes, RequiredSize)
ans = sb.ToString()
Exit Do
End If
End If
i += 1
Loop While Success
CloseHandle(ptrBuf)
SetupDiDestroyDeviceInfoList(h)
End If
Return ans
End Function
Each USB Device has a "Device Descriptor" which contains (among other things)
an "index" for its string values. The technique to retrieve the
DeviceDescriptor requires a "request packet" which carries the USB_DEVICE_DESCRIPTOR
structure as a payload. This requires some spooky pointer magic (and
careful memory allocation).
Private Function GetDeviceDescriptor(ByVal HubPath As String, ByVal PortNumber As _
Integer) As USB_DEVICE_DESCRIPTOR
Dim ans As New USB_DEVICE_DESCRIPTOR
Dim hHub, ptrDescReq, ptrDevDesc As IntPtr
Dim DescReq As New USB_DESCRIPTOR_REQUEST
Dim DevDesc As New USB_DEVICE_DESCRIPTOR
Dim nBytesReturned, nBytes As Integer
' open a handle to the USB hub
hHub = CreateFile(HubPath, GENERIC_WRITE, FILE_SHARE_WRITE, IntPtr.Zero, _
OPEN_EXISTING, 0, IntPtr.Zero)
If hHub.ToInt32 <> INVALID_HANDLE_VALUE Then
nBytes = BUFFER_SIZE
' build a "request" packet for a Device Descriptor
DescReq = New USB_DESCRIPTOR_REQUEST
DescReq.ConnectionIndex = PortNumber ' the "port number" on the hub
DescReq.SetupPacket.wValue = USB_DEVICE_DESCRIPTOR_TYPE << 8
DescReq.SetupPacket.wLength = CShort(nBytes - Marshal.SizeOf(DescReq))
ptrDescReq = Marshal.AllocHGlobal(BUFFER_SIZE)
Marshal.StructureToPtr(DescReq, ptrDescReq, True)
' Use an IOCTL call to request the Device Descriptor
If DeviceIoControl(hHub, IOCTL_USB_GET_DESCRIPTOR_FROM_NODE_CONNECTION, _
ptrDescReq, nBytes, ptrDescReq, nBytes, nBytesReturned, IntPtr.Zero) Then
' the pointer to the Device Descriptor is just "off the edge" of
' the descriptor request packet.
ptrDevDesc = New IntPtr(ptrDescReq.ToInt32 + Marshal.SizeOf(DescReq))
ans = CType(Marshal.PtrToStructure(ptrDevDesc, GetType( _
USB_DEVICE_DESCRIPTOR)), USB_DEVICE_DESCRIPTOR)
End If
' Clean up and go home
Marshal.FreeHGlobal(ptrDescReq)
CloseHandle(hHub)
End If
Return ans
End Function
The Device Descriptor only contains the "index" for the string values, so we
need to retrieve the String Descriptor to get the the value for the Serial Number
string. We use the same technique above to create a "request packet"
with a USB_STRING_DESCRIPTOR payload. However, this time the allocation of
memory needs to be zero-filled (otherwise we'd get garbage at the end of the
string). VB.Net doesn't have a direct technique for performing a zero-fill
operation, so we use a bit of a hack.
Private Function GetStringDescriptor(ByVal HubPath As String, ByVal PortNumber As _
Integer, ByVal Index As Integer) As String
Dim ans As String = ""
Dim hHub, ptrDescReq, ptrStrDesc As IntPtr
Dim DescReq As New USB_DESCRIPTOR_REQUEST
Dim StrDesc As New USB_STRING_DESCRIPTOR
Dim nBytesReturned, nBytes As Integer
' open a handle to the USB hub
hHub = CreateFile(HubPath, GENERIC_WRITE, FILE_SHARE_WRITE, IntPtr.Zero, _
OPEN_EXISTING, 0, IntPtr.Zero)
If hHub.ToInt32 <> INVALID_HANDLE_VALUE Then
nBytes = BUFFER_SIZE
' build a "request" packet for a StringDescriptor
DescReq = New USB_DESCRIPTOR_REQUEST
DescReq.ConnectionIndex = PortNumber
DescReq.SetupPacket.wValue = CShort((USB_STRING_DESCRIPTOR_TYPE << 8) + _
Index)
DescReq.SetupPacket.wLength = CShort(nBytes - Marshal.SizeOf(DescReq))
DescReq.SetupPacket.wIndex = &H409 ' Language Code
' we have to be a bit creative on how we "zero out" the buffer
Dim NullString As New String(Chr(0), BUFFER_SIZE \ Marshal.SystemDefaultCharSize)
ptrDescReq = Marshal.StringToHGlobalAuto(NullString)
Marshal.StructureToPtr(DescReq, ptrDescReq, True)
' Use an IOCTL call to request the String Descriptor
If DeviceIoControl(hHub, IOCTL_USB_GET_DESCRIPTOR_FROM_NODE_CONNECTION, _
ptrDescReq, nBytes, ptrDescReq, nBytes, nBytesReturned, IntPtr.Zero) Then
' the pointer to the String Descriptor is just "off the edge" of
' the descriptor request packet.
ptrStrDesc = New IntPtr(ptrDescReq.ToInt32 + Marshal.SizeOf(DescReq))
StrDesc = CType(Marshal.PtrToStructure(ptrStrDesc, GetType( _
USB_STRING_DESCRIPTOR)), USB_STRING_DESCRIPTOR)
ans = StrDesc.bString
End If
' Clean up and go home
Marshal.FreeHGlobal(ptrDescReq)
CloseHandle(hHub)
End If
Return ans
End Function
Note: If you prefer a class-based example of using the Win32 API for USB,
take a look at the link for a C# source code example at the end of this article
Additional Tools
The following tools from Microsoft are very helpful when dealing with USB
device programming
Documentation Links
Downloads/Links
Download the VB.Net Source code examples used in this article:
USB_SerialNumber.zip
Download the C# Source code for a class-based USB demonstration project:
USBView.zip
Read a related article on Introduction to Windows
Management Instrumentation
|