Microsoft Intune PowerShell script Report only

Get-AutopilotDeviceReport.ps1

Windows Autopilot registration health report: profile assignment status, enrollment state and last contact per device.

Overview#

Lists every Windows Autopilot device identity from Microsoft Graph (v1.0 /deviceManagement/windowsAutopilotDeviceIdentities) with hardware, group tag, deployment profile assignment and enrollment details. With -IncludeManagedDeviceDetails the identities are joined to the Intune managed device record (managedDeviceId) to add the enrolled device name, compliance state and last sync. A ProblemReason column flags devices without a profile, with a failed assignment or enrollment, or that have not contacted the service for -NotContactedDays days.

Safety: Report only — makes no changes to your tenant. Run Get-Help .\Get-AutopilotDeviceReport.ps1 -Full for the complete help text.

Parameters#

ParameterWhat it does
-GroupTagWildcard pattern applied to the Autopilot group tag, for example 'Kiosk*'.
-OnlyProblemsReturn only devices with a non-empty ProblemReason.
-IncludeManagedDeviceDetailsJoin the Intune managed device record to populate EnrolledDeviceName, ComplianceState, LastSyncDateTime and EnrolledDateTime. Requests DeviceManagementManagedDevices.Read.All in addition.
-NotContactedDaysDays without contact after which a device in enrollment state notContacted or unknown is flagged. Default 30.
-OutputPathCSV file to create. Defaults to .\Reports\AutopilotDevices_yyyyMMdd-HHmm.csv; the folder is created when missing.
-PassThruAlso emit the report objects to the pipeline.

Examples#

PowerShell
PS> .\Get-AutopilotDeviceReport.ps1

Exports all Autopilot registrations and prints a summary by profile assignment status and enrollment state.

PowerShell
PS> .\Get-AutopilotDeviceReport.ps1 -OnlyProblems -IncludeManagedDeviceDetails -PassThru | Format-Table SerialNumber, GroupTag, ProfileAssignmentStatus, EnrollmentState, ProblemReason

Shows only registrations that need attention, enriched with the Intune device record where one exists.

Permissions, modules and notes#

Author : Omer Eltayeb Blog : https://www.oeltayeb.com GitHub : https://github.com/omer-eltayeb Version : 1.0.0 Requires : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication Permissions : DeviceManagementServiceConfig.Read.All; DeviceManagementManagedDevices.Read.All only with -IncludeManagedDeviceDetails (delegated). Intune RBAC: Read Only Operator or any role with "Enrollment programs" read permission. Notes : v1.0 endpoints only. Autopilot requires Windows Autopilot licensing (Intune Plan 1 or an equivalent bundle). lastContactedDateTime is the last Autopilot service contact, not the Intune sync time. All date/time values are UTC.

Full source#

PowerShell · Get-AutopilotDeviceReport.ps1
<#
.SYNOPSIS
    Windows Autopilot registration health report: profile assignment status, enrollment state and last contact per device.
.DESCRIPTION
    Lists every Windows Autopilot device identity from Microsoft Graph (v1.0
    /deviceManagement/windowsAutopilotDeviceIdentities) with hardware, group tag, deployment profile
    assignment and enrollment details. With -IncludeManagedDeviceDetails the identities are joined to
    the Intune managed device record (managedDeviceId) to add the enrolled device name, compliance
    state and last sync. A ProblemReason column flags devices without a profile, with a failed
    assignment or enrollment, or that have not contacted the service for -NotContactedDays days.
.PARAMETER GroupTag
    Wildcard pattern applied to the Autopilot group tag, for example 'Kiosk*'.
.PARAMETER OnlyProblems
    Return only devices with a non-empty ProblemReason.
.PARAMETER IncludeManagedDeviceDetails
    Join the Intune managed device record to populate EnrolledDeviceName, ComplianceState, LastSyncDateTime
    and EnrolledDateTime. Requests DeviceManagementManagedDevices.Read.All in addition.
.PARAMETER NotContactedDays
    Days without contact after which a device in enrollment state notContacted or unknown is flagged. Default 30.
.PARAMETER OutputPath
    CSV file to create. Defaults to .\Reports\AutopilotDevices_yyyyMMdd-HHmm.csv; the folder is created when missing.
.PARAMETER PassThru
    Also emit the report objects to the pipeline.
.EXAMPLE
    PS> .\Get-AutopilotDeviceReport.ps1
    Exports all Autopilot registrations and prints a summary by profile assignment status and enrollment state.
.EXAMPLE
    PS> .\Get-AutopilotDeviceReport.ps1 -OnlyProblems -IncludeManagedDeviceDetails -PassThru | Format-Table SerialNumber, GroupTag, ProfileAssignmentStatus, EnrollmentState, ProblemReason
    Shows only registrations that need attention, enriched with the Intune device record where one exists.
.NOTES
    Author      : Omer Eltayeb
    Blog        : https://www.oeltayeb.com
    GitHub      : https://github.com/omer-eltayeb
    Version     : 1.0.0
    Requires    : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication
    Permissions : DeviceManagementServiceConfig.Read.All; DeviceManagementManagedDevices.Read.All only with
                  -IncludeManagedDeviceDetails (delegated). Intune RBAC: Read Only Operator or any role with
                  "Enrollment programs" read permission.
    Notes       : v1.0 endpoints only. Autopilot requires Windows Autopilot licensing (Intune Plan 1 or an equivalent
                  bundle). lastContactedDateTime is the last Autopilot service contact, not the Intune sync time.
                  All date/time values are UTC.
.LINK
    https://learn.microsoft.com/graph/api/intune-enrollment-windowsautopilotdeviceidentity-list
#>
#Requires -Version 5.1
#Requires -Modules Microsoft.Graph.Authentication

[CmdletBinding()]
param(
    [Parameter()]
    [string]$GroupTag,

    [Parameter()]
    [switch]$OnlyProblems,

    [Parameter()]
    [switch]$IncludeManagedDeviceDetails,

    [Parameter()]
    [ValidateRange(1, 3650)]
    [int]$NotContactedDays = 30,

    [Parameter()]
    [string]$OutputPath,

    [Parameter()]
    [switch]$PassThru
)

$ErrorActionPreference = 'Stop'

#region Helpers
function Connect-GraphIfNeeded {
    <# Connects to Microsoft Graph only when there is no usable session for the required scopes. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Scopes
    )
    $context = Get-MgContext
    $missingScopes = @()
    if ($null -ne $context) {
        $missingScopes = @($Scopes | Where-Object { $context.Scopes -notcontains $_ })
    }
    if ($null -eq $context -or $missingScopes.Count -gt 0) {
        Write-Verbose "Connecting to Microsoft Graph with scopes: $($Scopes -join ', ')"
        Connect-MgGraph -Scopes $Scopes -NoWelcome -ErrorAction Stop | Out-Null
    }
    else {
        Write-Verbose "Reusing existing Microsoft Graph session for $($context.Account)."
    }
}

function Invoke-GraphPaged {
    <# GET helper that follows @odata.nextLink and returns every item in 'value'. #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string]$Uri,

        [Parameter()]
        [hashtable]$Headers
    )
    $results = New-Object -TypeName System.Collections.Generic.List[object]
    $nextLink = $Uri
    while (-not [string]::IsNullOrEmpty($nextLink)) {
        $requestParams = @{ Method = 'GET'; Uri = $nextLink; OutputType = 'PSObject'; ErrorAction = 'Stop' }
        if ($null -ne $Headers) { $requestParams['Headers'] = $Headers }
        $response = Invoke-MgGraphRequest @requestParams
        if ($null -ne $response.PSObject.Properties['value']) {
            foreach ($item in $response.value) { $results.Add($item) }
        }
        elseif ($null -ne $response) {
            $results.Add($response)
        }
        $nextLink = $response.'@odata.nextLink'
    }
    return $results
}

function ConvertTo-UtcDateTime {
    <# Normalises a Graph date value (string or DateTime) to a UTC [datetime]; returns $null for empty or 0001-01-01 placeholders. #>
    [CmdletBinding()]
    param(
        [Parameter()]
        [object]$Value
    )
    if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { return $null }
    try { $parsed = [datetime]$Value } catch { return $null }
    if ($parsed.Year -le 1) { return $null }
    return $parsed.ToUniversalTime()
}
#endregion Helpers

#region Main
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
    $reportFolder = Join-Path -Path (Get-Location).Path -ChildPath 'Reports'
    $OutputPath = Join-Path -Path $reportFolder -ChildPath ('AutopilotDevices_{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmm'))
}
$outputFolder = Split-Path -Path $OutputPath -Parent
if (-not [string]::IsNullOrWhiteSpace($outputFolder) -and -not (Test-Path -Path $outputFolder)) {
    New-Item -Path $outputFolder -ItemType Directory -Force | Out-Null
}

$scopes = @('DeviceManagementServiceConfig.Read.All')
if ($IncludeManagedDeviceDetails) { $scopes += 'DeviceManagementManagedDevices.Read.All' }
try {
    Connect-GraphIfNeeded -Scopes $scopes
}
catch {
    throw "Failed to connect to Microsoft Graph: $($_.Exception.Message)"
}

$graphV1 = 'https://graph.microsoft.com/v1.0'
$selectProperties = @(
    'id', 'serialNumber', 'model', 'manufacturer', 'groupTag', 'purchaseOrderIdentifier', 'enrollmentState',
    'lastContactedDateTime', 'deploymentProfileAssignmentStatus', 'deploymentProfileAssignmentDetailedStatus',
    'deploymentProfileAssignedDateTime', 'azureActiveDirectoryDeviceId', 'managedDeviceId', 'addressableUserName',
    'userPrincipalName', 'displayName'
) -join ','
try {
    $identities = @(Invoke-GraphPaged -Uri ('{0}/deviceManagement/windowsAutopilotDeviceIdentities?$select={1}' -f $graphV1, $selectProperties))
}
catch {
    throw "Failed to retrieve Windows Autopilot device identities: $($_.Exception.Message)"
}
if (-not [string]::IsNullOrWhiteSpace($GroupTag)) {
    $identities = @($identities | Where-Object { $_.groupTag -like $GroupTag })
}
Write-Verbose ('{0} Autopilot device identities selected.' -f $identities.Count)

# Index Windows managed devices by id so each Autopilot identity can be joined without extra Graph calls.
$managedDevices = @{}
if ($IncludeManagedDeviceDetails) {
    try {
        $managedUri = $graphV1 + '/deviceManagement/managedDevices?$select=id,deviceName,complianceState,lastSyncDateTime,enrolledDateTime&$filter=operatingSystem eq ''Windows'''
        foreach ($managedDevice in @(Invoke-GraphPaged -Uri $managedUri)) {
            $managedDevices[[string]$managedDevice.id] = $managedDevice
        }
    }
    catch {
        throw "Failed to retrieve managed devices for the join: $($_.Exception.Message)"
    }
    Write-Verbose ('{0} Windows managed devices available for the join.' -f $managedDevices.Count)
}

$cutoff = [datetime]::UtcNow.AddDays(-$NotContactedDays)
$emptyGuid = '00000000-0000-0000-0000-000000000000'
$report = New-Object -TypeName System.Collections.Generic.List[object]
$index = 0
foreach ($identity in $identities) {
    $index++
    if ($index % 250 -eq 0) {
        Write-Progress -Activity 'Building Autopilot report' -Status ('{0} of {1}' -f $index, $identities.Count) -PercentComplete ([int](($index / $identities.Count) * 100))
    }

    $profileStatus = [string]$identity.deploymentProfileAssignmentStatus
    $enrollmentState = [string]$identity.enrollmentState
    $lastContacted = ConvertTo-UtcDateTime -Value $identity.lastContactedDateTime
    $profileAssigned = ConvertTo-UtcDateTime -Value $identity.deploymentProfileAssignedDateTime

    $reasons = @()
    if ($profileStatus -in @('notAssigned', 'failed')) { $reasons += ('Deployment profile {0}' -f $profileStatus) }
    if ($enrollmentState -eq 'failed') { $reasons += 'Enrollment failed' }
    if ($enrollmentState -in @('notContacted', 'unknown')) {
        # A device that never contacted the service has no lastContactedDateTime; fall back to the profile assignment date.
        $referenceDate = $lastContacted
        if ($null -eq $referenceDate) { $referenceDate = $profileAssigned }
        if ($null -eq $referenceDate -or $referenceDate -lt $cutoff) {
            $reasons += ('Enrollment state {0} for more than {1} days' -f $enrollmentState, $NotContactedDays)
        }
    }

    $managedDeviceId = [string]$identity.managedDeviceId
    if ($managedDeviceId -eq $emptyGuid) { $managedDeviceId = $null }
    $managed = $null
    if (-not [string]::IsNullOrEmpty($managedDeviceId) -and $managedDevices.ContainsKey($managedDeviceId)) {
        $managed = $managedDevices[$managedDeviceId]
    }

    $report.Add([PSCustomObject]@{
            SerialNumber                    = $identity.serialNumber
            DisplayName                     = $identity.displayName
            Manufacturer                    = $identity.manufacturer
            Model                           = $identity.model
            GroupTag                        = $identity.groupTag
            PurchaseOrder                   = $identity.purchaseOrderIdentifier
            EnrollmentState                 = $enrollmentState
            LastContactedDateTime           = $lastContacted
            ProfileAssignmentStatus         = $profileStatus
            ProfileAssignmentDetailedStatus = $identity.deploymentProfileAssignmentDetailedStatus
            ProfileAssignedDateTime         = $profileAssigned
            UserPrincipalName               = $identity.userPrincipalName
            AddressableUserName             = $identity.addressableUserName
            EntraDeviceId                   = $identity.azureActiveDirectoryDeviceId
            ManagedDeviceId                 = $managedDeviceId
            EnrolledDeviceName              = $managed.deviceName
            ComplianceState                 = $managed.complianceState
            LastSyncDateTime                = ConvertTo-UtcDateTime -Value $managed.lastSyncDateTime
            EnrolledDateTime                = ConvertTo-UtcDateTime -Value $managed.enrolledDateTime
            ProblemReason                   = ($reasons -join '; ')
            AutopilotDeviceId               = $identity.id
        })
}
Write-Progress -Activity 'Building Autopilot report' -Completed

$output = $report
if ($OnlyProblems) {
    $output = @($report | Where-Object { -not [string]::IsNullOrEmpty($_.ProblemReason) })
}

if ($output.Count -gt 0) {
    $output | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
    Write-Host ('Report written to {0}' -f $OutputPath) -ForegroundColor Green
}
else {
    Write-Warning 'No Autopilot devices matched the specified criteria; no CSV file was written.'
}

$problemCount = @($report | Where-Object { -not [string]::IsNullOrEmpty($_.ProblemReason) }).Count
$problemColour = 'Green'
if ($problemCount -gt 0) { $problemColour = 'Yellow' }
Write-Host ''
Write-Host ('Autopilot devices        : {0}' -f $report.Count) -ForegroundColor Cyan
Write-Host ('Devices needing attention: {0}' -f $problemCount) -ForegroundColor $problemColour
Write-Host 'By deployment profile assignment status:' -ForegroundColor Cyan
foreach ($group in ($report | Group-Object -Property ProfileAssignmentStatus | Sort-Object -Property Count -Descending)) {
    Write-Host ('  {0,-28} {1,6}' -f $group.Name, $group.Count)
}
Write-Host 'By enrollment state:' -ForegroundColor Cyan
foreach ($group in ($report | Group-Object -Property EnrollmentState | Sort-Object -Property Count -Descending)) {
    Write-Host ('  {0,-28} {1,6}' -f $group.Name, $group.Count)
}

if ($PassThru) {
    $output
}
#endregion Main

Scripts are provided as-is under the MIT licence. Review the permissions a script requests, test in a non-production tenant, and use -WhatIf before letting any script change anything.

Found a bug or have an improvement? Open an issue on GitHub or email me.