Skip to content

Instantly share code, notes, and snippets.

@adthom
Last active June 17, 2024 14:24
Show Gist options
  • Save adthom/b703078806adeb71fe860929df0bd4c1 to your computer and use it in GitHub Desktop.
Save adthom/b703078806adeb71fe860929df0bd4c1 to your computer and use it in GitHub Desktop.
Teams BYOD Rooms/Spaces Manual Device Discovery Script
<#
.SYNOPSIS
Queries and exports attached peripheral information
.DESCRIPTION
Queries and exports attached peripheral information for use in Microsoft Teams Bring Your Own Device (BYOD)
data entry in the Pro Management Portal (PMP) for enhanced reporting
.EXAMPLE
.\Get-TeamsBYODSpaceDevices.ps1
.NOTES
Version: 1.1
Author: andthom@microsoft.com, rowille@microsoft.com
Creation Date: March 2024
#>
## Disclaimer
# (c)2024 Microsoft Corporation. All rights reserved. This document is provided "as-is." Information and views expressed in this document,
# including URL and other Internet Web site references, may change without notice. You bear the risk of using it.
# This document does not provide you with any legal rights to any intellectual property in any Microsoft product.
# You may copy and use this document for your internal, reference purposes. You may modify this document for your internal purposes.
$CurrentDevices = [Collections.Generic.HashSet[string]]::new([string[]]@((Get-PnpDevice -PresentOnly).DeviceID), [StringComparer]::OrdinalIgnoreCase)
while ($true) {
Write-Host "Please Connect The BYOD Room or Workspace Devices Now"
$null = Read-Host "Press Enter When Ready"
Write-Host "Waiting 15 seconds for devices to be ready..."
Start-Sleep -Seconds 15
Write-Host "Checking for new devices..."
$NewDevices = [Collections.Generic.HashSet[string]]::new([string[]]@((Get-PnpDevice -PresentOnly).DeviceID), [StringComparer]::OrdinalIgnoreCase)
$NewDevices.ExceptWith($CurrentDevices)
if (!$NewDevices.Count) {
Write-Host "No new devices found, Are you sure you connected the devices when prompted?" -ForegroundColor Red
if (!$env:RUN_FOR_ALL_DEVICES -or $env:RUN_FOR_ALL_DEVICES -eq '0') { return }
$NewDevices = $CurrentDevices
}
Write-Host "Getting device information..."
$UPN = $null
$DisplayName = $null
$GroupID = $null
while (!$UPN) {
$UPN = Read-Host "Enter the BYOD Room or Workspace Account's User Principal Name (UPN)"
}
$DisplayName = Read-Host "Enter the BYOD Room or Workspace Account's Display Name (default: none)"
$GroupID = Read-Host "Enter the Grouping ID to use for this data collection (default: none)"
if (!$Local:OutputFilePath -or !(Test-Path $OutputFilePath)) {
$DefaultFilePath = [Environment]::GetFolderPath('Desktop')
$OutputFilePath = Read-Host "Enter the folder path where the PERIPHERALS.csv file will be saved (default: $DefaultFilePath)"
if (!$OutputFilePath) {
$OutputFilePath = $DefaultFilePath
}
$OutputFilePath = [IO.Path]::Combine($OutputFilePath, 'PERIPHERALS.csv')
}
else {
Write-Host "Using the existing file $OutputFilePath to store the data."
}
Write-Host "Gathering peripheral data for the newly connected devices..."
function Get-DeviceProperties {
[CmdletBinding()]
[OutputType([PSCustomObject])]
param(
[Parameter(Mandatory = $true, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
[string]
$DeviceID,
[hashtable]
$Properties = @{
'DEVPKEY_NAME' = 'Name'
'DEVPKEY_Device_BusReportedDeviceDesc' = 'NameFromBus'
'DEVPKEY_Device_Service' = 'Service'
'DEVPKEY_Device_Class' = 'PNPClass'
'DEVPKEY_Device_Parent' = 'ParentID'
}
)
process {
$Instance = [Management.ManagementObject]::new('Win32_PnPEntity.DeviceID="{0}"' -f $DeviceID.Replace('\','\\'))
$params = $Instance.GetMethodParameters('GetDeviceProperties')
$params['devicePropertyKeys'] = [string[]]$Properties.Keys
$result = $Instance.InvokeMethod('GetDeviceProperties', $params, $null)
$objHash = [ordered]@{
DeviceID = $DeviceID
}
foreach ($property in $result['deviceProperties']) {
$name = $property['KeyName']
if ([string]::IsNullOrEmpty($name)) {
$name = $property['key']
}
$objHash[$Properties[$name]] = $property['Data']
}
[PSCustomObject]$objHash
}
}
$Devices = $NewDevices | Get-DeviceProperties
$USBCompositeDevices = @($Devices | Where-Object { $_.PNPClass -in 'USB' })
$Stack = [Collections.Generic.Stack[object]]$USBCompositeDevices
$ProcessedIDs = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
while ($Stack.Count) {
$CurrentDevice = $Stack.Pop()
if (!$ProcessedIDs.Add($CurrentDevice.DeviceID)) {
continue
}
$Children = $Devices.Where({ $CurrentDevice.DeviceID -eq $_.ParentID })
$CurrentDevice | Add-Member -NotePropertyName Children -NotePropertyValue $Children
foreach ($Child in $CurrentDevice.Children) {
$Stack.Push($Child)
$Devices = $Devices.Where({ $_.DeviceID -ne $Child.DeviceID })
}
}
Write-Host "Processing the discovered peripheral data..."
try {
$null = [Native.HID+DeviceInfo]::new()
}
catch {
$Signature = @'
const uint FILE_SHARE_READ = 0x00000001;
const uint FILE_SHARE_WRITE = 0x00000002;
const uint OPEN_EXISTING = 0x00000003;
const uint GENERIC_READ = 0x80000000;
const uint GENERIC_WRITE = 0x40000000;
const uint FILE_FLAG_OVERLAPPED = 0x40000000;
const int ERROR_INVALID_USER_BUFFER = 0x6F8;
[DllImport("kernel32", SetLastError = true)]
static extern SafeFileHandle CreateFile(string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile);
[DllImport("kernel32", SetLastError = true)]
static extern bool CloseHandle(IntPtr hObject);
internal struct HIDD_ATTRIBUTES
{
public int Size;
public ushort VendorID;
public ushort ProductID;
public ushort VersionNumber;
public static HIDD_ATTRIBUTES Create()
{
return new HIDD_ATTRIBUTES()
{
Size = Marshal.SizeOf(typeof(HIDD_ATTRIBUTES))
};
}
}
static SafeFileHandle GetDeviceHandle(string lpFileName)
{
var hDevice = CreateFile(lpFileName, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, IntPtr.Zero, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, IntPtr.Zero);
if (Marshal.GetLastWin32Error() != 0)
{
return SafeFileHandle.Null;
}
return hDevice;
}
[DllImport("hid", SetLastError = true)]
static extern bool HidD_GetAttributes(SafeFileHandle hidDeviceObject, out HIDD_ATTRIBUTES attributes);
[DllImport("hid", SetLastError = true, CharSet = CharSet.Unicode)]
static extern bool HidD_GetSerialNumberString(SafeFileHandle hidDeviceObject, StringBuilder buffer, int bufferLength);
static string HIDGetSerialNumberString(SafeFileHandle hDevice)
{
var builder = new StringBuilder(256);
while (builder.Capacity < 4094)
{
if (HidD_GetSerialNumberString(hDevice, builder, builder.Capacity))
return builder.ToString();
if (Marshal.GetLastWin32Error() != ERROR_INVALID_USER_BUFFER)
break;
builder.Capacity *= 2;
}
return null;
}
public static DeviceInfo GetHidDevice(string devicePath)
{
using (var hDevice = GetDeviceHandle(devicePath))
{
if (hDevice.IsInvalid)
return null;
var attributes = HIDD_ATTRIBUTES.Create();
HidD_GetAttributes(hDevice, out attributes);
if (Marshal.GetLastWin32Error() != 0)
return null;
return new DeviceInfo
{
SerialNumber = HIDGetSerialNumberString(hDevice),
VendorId = attributes.VendorID,
ProductId = attributes.ProductID,
};
}
}
public class DeviceInfo
{
public string SerialNumber { get; set; }
public ushort VendorId { get; set; }
public ushort ProductId { get; set; }
}
internal static readonly IntPtr INVALID_HANDLE = new IntPtr(-1);
public class SafeFileHandle : SafeHandle
{
public static readonly SafeFileHandle Invalid = new SafeFileHandle();
public static readonly SafeFileHandle Null = new SafeFileHandle(IntPtr.Zero, false);
public SafeFileHandle()
: base(INVALID_HANDLE, true)
{
}
public SafeFileHandle(IntPtr preexistingHandle, bool ownsHandle = true)
: base(INVALID_HANDLE, ownsHandle)
{
this.SetHandle(preexistingHandle);
}
public override bool IsInvalid { get { return this.handle == INVALID_HANDLE || this.handle == IntPtr.Zero; } }
protected override bool ReleaseHandle() { return CloseHandle(this.handle); }
}
'@
Add-Type -MemberDefinition $Signature -Name HID -Namespace Native -UsingNamespace System.Text
}
$DeviceSerialNumbers = @{}
$NewDevices | ForEach-Object {
[Native.HID]::GetHidDevice('\\?\' + $_.Replace('\','#') + '#{4d1e55b2-f16f-11cf-88cb-001111000030}')
} | Where-Object { $null -ne $_ } | ForEach-Object {
$Lookup = "$($_.VendorId)-$($_.ProductId)"
if ($DeviceSerialNumbers.ContainsKey($Lookup) -and $DeviceSerialNumbers[$Lookup] -ne $_.SerialNumber) {
Write-Warning "Duplicate device found with Vendor ID: $($_.VendorId) and Product ID: $($_.ProductId). Changing serial number from $($DeviceSerialNumbers[$Lookup]) to $($_.SerialNumber)."
}
$DeviceSerialNumbers[$Lookup] = $_.SerialNumber
}
$USBDeviceIDPattern = 'VID_(?<vid>[0-9A-F]{4})&PID_(?<pid>[0-9A-F]{4})'
$MediaDevices = foreach ($Device in $USBCompositeDevices |
Where-Object { $_.Children |
Where-Object { $_.PNPClass -in @('Image','Camera') -or ($_.Children |
Where-Object { $_.PNPClass -eq 'AudioEndpoint' }) } }) {
if ($Device.DeviceID -notmatch $USBDeviceIDPattern) {
continue
}
$DeviceVID = [Int32]::Parse($Matches['vid'],[Globalization.NumberStyles]::AllowHexSpecifier)
$DevicePID = [Int32]::Parse($Matches['pid'],[Globalization.NumberStyles]::AllowHexSpecifier)
$DeviceSerialNumber = $DeviceSerialNumbers["$DeviceVID-$DevicePID"]
$Camera = @($Device.Children | Where-Object { $_.PNPClass -in @('Camera','Image') } | Sort-Object DeviceID)
$Audio = @($Device.Children | Where-Object { $_.Children | Where-Object { $_.PNPClass -eq 'AudioEndpoint' } } | Sort-Object DeviceID)
$AudioEndpoints = @($Audio.Children | Where-Object { $_.PNPClass -eq 'AudioEndpoint' -and $_.DeviceID } | ForEach-Object { [PSCustomObject]@{Id=$_.DeviceID.Split('\',3)[2]; Name=$_.Name} })
$Speaker = @($AudioEndpoints | Where-Object { $_.Id.StartsWith('{0.0.0.') } | Sort-Object Id)
$Microphone = @($AudioEndpoints | Where-Object { $_.Id.StartsWith('{0.0.1.') } | Sort-Object Id)
if (!$Camera -and !$Speaker -and !$Microphone) {
continue
}
$DeviceType = & {
if ($Speaker -and !$Microphone -and !$Camera) {
return 'Speaker'
}
if (!$Speaker -and $Microphone -and !$Camera) {
return 'Microphone'
}
if (!$Speaker -and !$Microphone -and $Camera) {
return 'Camera'
}
if ($Speaker -and $Microphone -and !$Camera) {
return 'CompositeAudioDevice'
}
if ($Speaker -and $Microphone -and $Camera) {
return 'CompositeAudioVideoDevice'
}
return 'CompositeVideoDevice'
}
$DeviceName = switch ($DeviceType) {
'Speaker' { $Speaker[0].Name }
'Microphone' { $Microphone[0].Name }
'Camera' { $Camera[0].Name }
'CompositeAudioDevice' { $(if (![string]::IsNullOrEmpty($Device.NameFromBus)) { $Device.NameFromBus } else { $Microphone[0].Name }) }
'CompositeVideoDevice' { 'Composite - ' + $(if (![string]::IsNullOrEmpty($Device.NameFromBus)) { $Device.NameFromBus } else { $Camera[0].Name }) }
'CompositeAudioVideoDevice' { $(if (![string]::IsNullOrEmpty($Device.NameFromBus)) { $Device.NameFromBus } else { $Camera[0].Name }) }
default { $(if (![string]::IsNullOrEmpty($Device.NameFromBus)) { $Device.NameFromBus } else { $Device.Name }) }
}
[PSCustomObject][ordered]@{
'Product ID' = "$DevicePID"
'Vendor ID' = "$DeviceVID"
'Serial Number' = "$DeviceSerialNumber"
'Peripheral Name' = "$DeviceName"
'Peripheral Type' = "$DeviceType"
}
}
$MonitorDevices = foreach ($Device in $Devices | Where-Object { $_.PNPClass -eq 'Monitor' -and $_.DeviceID }) {
$scope = [Management.ManagementScope]::new('root/WMI')
$scope.Connect()
$QueryString = 'SELECT InstanceName,ProductCodeID,ManufacturerName,SerialNumberID,UserFriendlyName FROM WmiMonitorID WHERE Active = TRUE AND InstanceName LIKE ''{0}%''' -f $Device.DeviceID.Replace('\','\\')
$query = [Management.ObjectQuery]::new('WQL', $QueryString)
$searcher = [Management.ManagementObjectSearcher]::new($scope, $query)
$WmiMonitorID = try { $searcher.Get() | Select-Object -First 1 } catch {}
if (!$WmiMonitorID) {
continue
}
$QueryString = 'SELECT VideoOutputTechnology FROM WmiMonitorConnectionParams WHERE InstanceName LIKE ''{0}%''' -f $WmiMonitorID['InstanceName'].Replace('\','\\')
$query = [Management.ObjectQuery]::new('WQL', $QueryString)
$searcher = [Management.ManagementObjectSearcher]::new($scope, $query)
$GoodOutputType = try { $searcher.Get() | Where-Object { $_['VideoOutputTechnology'] -notin @(6, 11, 13, 0x80000000) } } catch {}
if (!$GoodOutputType -or $GoodOutputType.Count -eq 0) {
continue
}
$DevicePID = [Convert]::ToUInt32([Text.Encoding]::ASCII.GetString($WmiMonitorID['ProductCodeID'], 0, 4), 16)
$DeviceVID = ($WmiMonitorID['ManufacturerName'][0] - 64 -shl 10) -bor ($WmiMonitorID['ManufacturerName'][1] - 64 -shl 5) -bor ($WmiMonitorID['ManufacturerName'][2] - 64)
$DeviceSerialNumber = [Text.Encoding]::ASCII.GetString($WmiMonitorID['SerialNumberID']).TrimEnd([char]0)
$DeviceName = [Text.Encoding]::ASCII.GetString($WmiMonitorID['UserFriendlyName']).TrimEnd([char]0)
[PSCustomObject][ordered]@{
'Product ID' = "$DevicePID"
'Vendor ID' = "$DeviceVID"
'Serial Number' = "$DeviceSerialNumber"
'Peripheral Name' = "$DeviceName"
'Peripheral Type' = 'Screen'
}
}
$DataToExport = @($MediaDevices) + @($MonitorDevices) | Select-Object @{
Name = 'Account'; Expression = { $UPN }
}, @{
Name = 'Display Name'; Expression = { $DisplayName }
}, *, @{
Name = 'GROUP_ID'; Expression = { $GroupID }
} | Where-Object {
$_.'Peripheral Type' -notin @('Unknown','Camera')
}
if ((Test-Path $OutputFilePath)) {
while($true) {
try {
$CurrentContents = Import-Csv -Path $OutputFilePath -ErrorAction Stop
break
}
catch [IO.IOException] {
if (!(Test-Path $OutputFilePath)) {
$CurrentContents = @()
break
}
Write-Warning "The file $OutputFilePath is currently in use. Please close the file and press Enter to retry."
$null = Read-Host
}
}
$DataToExport = @($DataToExport) + @($CurrentContents) |
Sort-Object -Property 'Product ID', 'Vendor ID', 'Serial Number', 'Peripheral Name', 'Peripheral Type', GROUP_ID -Unique
}
# Update the CSV file
while($true) {
try {
$DataToExport | Export-Csv -Path $OutputFilePath -NoTypeInformation -ErrorAction Stop
break
}
catch [IO.IOException] {
Write-Warning "The file $OutputFilePath is currently in use. Please close the file and press Enter to retry."
$null = Read-Host
}
}
Write-Host "Your discovered peripheral data has been collected and exported to $OutputFilePath." -ForegroundColor Green
Write-Host "Here is a preview of the results that were exported:"
$DataToExport | Format-Table -AutoSize
$Continue = Read-Host 'Are you finished collecting BYOD Rooms or Workspaces? (default: no)'
if ($Continue -and $Continue[0] -eq 'y') {
break
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment