Create custom Intune reports with Microsoft Graph, Azure Automation and Power BI

Microsoft Endpoint Manager aka Intune has been around for a while now and has evolved quite significantly since its early days and the old Silverlight portal (remember that?). Historically Intune hasn’t been particularly good with its reporting capability, but since end 2019 and the announcement of the new reporting framework, things are starting to improve with more built-in reports appearing all the time and now the ability to create your own Azure monitor workbooks from log analytics – welcome additions for sure.

That being said, if you use Power BI as a corporate reporting service there’s no better place to create custom reports according to your needs and share those reports with interested parties without having to give them access to Intune and the MEM portal. Intune data can be queried using Microsoft Graph yet there is currently no native way to use Microsoft Graph as data source in Power BI short of developing a custom connector. With the advent of log analytics data for Intune, we will be able to export log analytics queries to Power BI using M query language which looks promising.

In the meantime, we need to use a little creativity to get data out of Intune and into Power BI to furnish a custom report. I’ve seen examples of using Logic Apps and Function app in Azure as an intermediary process, which is cool. I decided to take a slightly different approach to this and use an Azure automation account to simply export data from Microsoft Graph on a schedule and dump it into an Azure storage account. Power BI supports the use of blob storage as a data source so this works quite nicely.

  • An Azure automation runbook queries Microsoft Graph, organizes the data a bit then exports it into CSV files
  • The CSV files are then uploaded as blobs in a container in a storage account
  • Power BI then uses those CSV files as its datasource allowing us to create custom reports from the data

There are a few advantages to this approach:

  • You can use Power BI to create your own custom reports from any Microsoft Graph data across Microsoft 365, and you can combine data from different sources in a single report
  • Reports support using a scheduled refresh in the Power BI service for keeping data up-to-date
  • Everything is in the cloud – there is no requirement for on-prem resources or a data gateway
  • You can use a managed identity or a Run as account for simplified, secure no-credential authentication
  • Graph data can be manipulated using PowerShell before export allowing customization of the final data set

On the last point, some data in Microsoft Graph contains nested data, eg a field may contain its own set of key-value pairs and you would want to expand these out into their own fields to report on them. Or you may wish to create your own calculated fields from the data, for example how many days since the last sync of an enrolled device.

Additionally Microsoft recently announced support for enabling a managed identity for an automation account, and this is excellent for simplifying and improving the security of access to Microsoft Graph.

The only slight disadvantage to this approach perhaps is that data in the report won’t be live – it will only be as good as your refresh schedule.

Just to give an example, here are a couple of screenshots of a report I created from Intune data for enrolled devices, including Android, iOS and Windows.

I’ve created a new docs site where you can download this report as a template, the runbook used to export the data as well as access detailed instructions on how to set up a data export from MS Graph into Power BI.

docs.smsagent.blog

There’s also a bonus runbook that exports a list of unhealthy MEMCM clients and send it as an email report. We don’t want any unhealthy MEMCM clients now do we?!

Deploying HP BIOS Updates – a real world example

Not so long ago HP published a customer advisory listing a number of their models that need to be on the latest BIOS release to be upgraded to Windows 10 2004. Since we were getting ready to rollout 20H2 we encountered some affected models in piloting, which prompted me to find that advisory and then get the BIOS updated on affected devices.

To be honest, until now we’ve never pushed out BIOS updates to anyone, but to get these devices updated to 20H2 we now had no choice. In this post I’m just going to share how we did that. For us, good user experience is critical but finding the balance between keeping devices secure and up-to-date without being too disruptive to the user can be a challenge!

First off was to create a script that could update the BIOS on any supported HP workstation, without needing to package and distribute BIOS update content. I know other equally handsome community members have shared some great scripts and solutions for doing BIOS updates, but I decided to create my own in this instance to meet our particular requirements and afford a bit more control over the update process. I considered using the HP Client Management Script Library which seems purpose-built for this kind of task and is a great resource, but I preferred not to have the dependency of an external PowerShell module and its requirements.

I published a version of this script in Github here. The script does the following:

  • Creates a working directory under ProgramData
  • Disables the IE first run wizard which causes a problem for the Invoke-WebRequest cmdlet when running in a context where IE hasn’t been initialised
  • Checks the HP Image Assistant (HPIA) web page for the latest version and downloads it
  • Extracts and runs the HPIA to identify the latest applicable BIOS update (if any)
  • If a BIOS update is available, downloads and extracts the softpaq
  • Checks if a BIOS password has been set, if so creates an encrypted password file as required by the firmware update utility
  • Runs the firmware update utility to stage the update
  • Everything is logged to the working directory and the logs are uploaded to Azure blob storage upon completion, or if something fails, so we can review them without requiring remote access to the user’s computer

It all runs silently without any user interaction and it can be run on any HP model that the HPIA supports.

In Production however, we used a slightly modified version of this script. Since there was the possibility that there could be unknown BIOS passwords in use out there, we decided not to try to flash the BIOS using an encrypted password file, but instead try to remove the BIOS password altogether (temporarily!) When the BIOS update is staged it simply copies the password file to the staging volume – it doesn’t check whether the password is correct or not. If it is not correct, the user would then be asked for the correct password when the BIOS is flashed and that is not cool! Removing the password meant that the user could never be unexpectedly prompted for the password in the event that the provided password file was incorrect. Of course, to remove the password you also have to know the password, so we tried the ones we knew and if they worked, great, if they didn’t, the script would simply exit out as a failsafe.

We have a compliance baseline deployed with MEMCM that sets the BIOS password on any managed workstation that does not have one set, so after the machine rebooted, the BIOS flashed and machine starts up again, before long the CB would run and set the password again.

Doing this also meant that we needed to ensure the computer was restarted asap after the update was staged – and for another reason as well – Bitlocker encryption is suspended until the update is applied and the machine restarted.

Because we didn’t want to force the update on users and force a restart on them, we decided to package the script as an application in MEMCM. This meant a couple of things:

  • We could put a nice corporate logo on the app and make it available for install in the Software Center
  • We could handle the return codes with custom actions. In this case, we are expecting the PowerShell script to exit successfully with code 0, and when it does we’ve set that code to be a soft reboot so that MEMCM restart notifications are then displayed to the user.

As a detection method, the following PowerShell code was used. This simply detects if the last write time of the log file has changed within the last hour, if it has, it’s installed. Longer than an hour and it’s available for install again if needed.

$Log = Get-ChildItem C:\ProgramData\Contoso\HP_BIOS_Update -Recurse -Include HP_BIOS_Update.log -ErrorAction SilentlyContinue | 
    where {([DateTime]::Now - $_.LastWriteTime).TotalHours -le 1}
If ($Log)
{
    Write-Host "Installed"
}

We then deployed the application with an available deployment. We communicated with the users directly to inform them a BIOS update needed to be installed on their device in order to remain secure and up-to-date, and directed them to the Software Center to install it themselves.

We also prepared collections in SCCM using query-based membership rules to identify the machines that were affected by the HP advisory, and an SQL query to find the same information and pull the full user name and email address from inventoried data.

The script does contain the BIOS password in clear text which, or course, may not meet your security requirements, although for us this password is not really that critical – it’s just there to help prevent the user from making unauthorized changes in the BIOS. In our Production script though, we simply converted these to base64 before adding them to the script to at least provide some masking. But for greater security you could consider storing the password in Azure key vault and fetch it at run time with a web request, for example.

If you wish to use the script in your own environment, you’ll need to change the location of the working directory as you desire. Additionally if you wish to upload the log files to an Azure storage container, you’ll need to have or create a container and add the URL and the SAS token string to the script, or else just comment out the Upload-LogFilesToAzure function where it’s used. I’m a big fan of sending log files to Azure storage especially during this season where many are working from home and may not be corporate connected. You can just use Azure Storage Explorer to download the log files which will open up in CMTrace if that’s your default log viewer.

Hope this is helpful to someone! The PS script is below.

#####################
## HP BIOS UPDATER ##
#####################
# Params
$HPIAWebUrl = "https://ftp.hp.com/pub/caps-softpaq/cmit/HPIA.html" # Static web page of the HP Image Assistant
$BIOSPassword = "MyPassword"
$script:ContainerURL = "https://mystorageaccount.blob.core.windows.net/mycontainer" # URL of your Azure blob storage container
$script:FolderPath = "HP_BIOS_Updates" # the subfolder to put logs into in the storage container
$script:SASToken = "mysastoken" # the SAS token string for the container (with write permission)
$ProgressPreference = 'SilentlyContinue' # to speed up web requests
################################
## Create Directory Structure ##
################################
$RootFolder = $env:ProgramData
$ParentFolderName = "Contoso"
$ChildFolderName = "HP_BIOS_Update"
$ChildFolderName2 = Get-Date Format "yyyy-MMM-dd_HH.mm.ss"
$script:WorkingDirectory = "$RootFolder\$ParentFolderName\$ChildFolderName\$ChildFolderName2"
try
{
[void][System.IO.Directory]::CreateDirectory($WorkingDirectory)
}
catch
{
throw
}
# Function write to a log file in ccmtrace format
Function script:Write-Log {
param (
[Parameter(Mandatory = $true)]
[string]$Message,
[Parameter()]
[ValidateSet(1, 2, 3)] # 1-Info, 2-Warning, 3-Error
[int]$LogLevel = 1,
[Parameter(Mandatory = $true)]
[string]$Component,
[Parameter(Mandatory = $false)]
[object]$Exception
)
$LogFile = "$WorkingDirectory\HP_BIOS_Update.log"
If ($Exception)
{
[String]$Message = "$Message" + "$Exception"
}
$TimeGenerated = "$(Get-Date Format HH:mm:ss).$((Get-Date).Millisecond)+000"
$Line = '<![LOG[{0}]LOG]!><time="{1}" date="{2}" component="{3}" context="" type="{4}" thread="" file="">'
$LineFormat = $Message, $TimeGenerated, (Get-Date Format MMddyyyy), $Component, $LogLevel
$Line = $Line -f $LineFormat
# Write to log
Add-Content Value $Line Path $LogFile ErrorAction SilentlyContinue
}
# Function to upload log file to Azure Blob storage
Function Upload-LogFilesToAzure {
$Date = Get-date Format "yyyy-MM-dd_HH.mm.ss"
$HpFirmwareUpdRecLog = Get-ChildItem Path $WorkingDirectory Include HpFirmwareUpdRec.log Recurse ErrorAction SilentlyContinue
$HPBIOSUPDRECLog = Get-ChildItem Path $WorkingDirectory Include HPBIOSUPDREC64.log Recurse ErrorAction SilentlyContinue
If ($HpFirmwareUpdRecLog)
{
$File = $HpFirmwareUpdRecLog
}
ElseIf ($HPBIOSUPDRECLog)
{
$File = $HPBIOSUPDRECLog
}
Else{}
If ($File)
{
$Body = Get-Content $($File.FullName) Raw ErrorAction SilentlyContinue
If ($Body)
{
$URI = "$ContainerURL/$FolderPath/$($Env:COMPUTERNAME)`_$Date`_$($File.Name)$SASToken"
$Headers = @{
'x-ms-content-length' = $($File.Length)
'x-ms-blob-type' = 'BlockBlob'
}
Invoke-WebRequest Uri $URI Method PUT Headers $Headers Body $Body ErrorAction SilentlyContinue
}
}
$File2 = Get-Item $WorkingDirectory\HP_BIOS_Update.log ErrorAction SilentlyContinue
$Body2 = Get-Content $($File2.FullName) Raw ErrorAction SilentlyContinue
If ($Body2)
{
$URI2 = "$ContainerURL/$FolderPath/$($Env:COMPUTERNAME)`_$Date`_$($File2.Name)$SASToken"
$Headers2 = @{
'x-ms-content-length' = $($File2.Length)
'x-ms-blob-type' = 'BlockBlob'
}
Invoke-WebRequest Uri $URI2 Method PUT Headers $Headers2 Body $Body2 ErrorAction SilentlyContinue
}
}
Write-Log Message "#######################" Component "Preparation"
Write-Log Message "## Starting BIOS update run ##" Component "Preparation"
Write-Log Message "#######################" Component "Preparation"
#################################
## Disable IE First Run Wizard ##
#################################
# This prevents an error running Invoke-WebRequest when IE has not yet been run in the current context
Write-Log Message "Disabling IE first run wizard" Component "Preparation"
$null = New-Item Path "HKLM:\SOFTWARE\Policies\Microsoft" Name "Internet Explorer" Force
$null = New-Item Path "HKLM:\SOFTWARE\Policies\Microsoft\Internet Explorer" Name "Main" Force
$null = New-ItemProperty Path "HKLM:\SOFTWARE\Policies\Microsoft\Internet Explorer\Main" Name "DisableFirstRunCustomize" PropertyType DWORD Value 1 Force
##########################
## Get latest HPIA Info ##
##########################
Write-Log Message "Finding info for latest version of HP Image Assistant (HPIA)" Component "DownloadHPIA"
try
{
$HTML = Invoke-WebRequest Uri $HPIAWebUrl ErrorAction Stop
}
catch
{
Write-Log Message "Failed to download the HPIA web page. $($_.Exception.Message)" Component "DownloadHPIA" LogLevel 3
UploadLogFilesToAzure
throw
}
$HPIASoftPaqNumber = ($HTML.Links | Where {$_.href -match "hp-hpia-"}).outerText
$HPIADownloadURL = ($HTML.Links | Where {$_.href -match "hp-hpia-"}).href
$HPIAFileName = $HPIADownloadURL.Split('/')[-1]
Write-Log Message "SoftPaq number is $HPIASoftPaqNumber" Component "DownloadHPIA"
Write-Log Message "Download URL is $HPIADownloadURL" Component "DownloadHPIA"
###################
## Download HPIA ##
###################
Write-Log Message "Downloading the HPIA" Component "DownloadHPIA"
try
{
$ExistingBitsJob = Get-BitsTransfer Name "$HPIAFileName" AllUsers ErrorAction SilentlyContinue
If ($ExistingBitsJob)
{
Write-Log Message "An existing BITS tranfer was found. Cleaning it up." Component "DownloadHPIA" LogLevel 2
Remove-BitsTransfer BitsJob $ExistingBitsJob
}
$BitsJob = Start-BitsTransfer Source $HPIADownloadURL Destination $WorkingDirectory\$HPIAFileName Asynchronous DisplayName "$HPIAFileName" Description "HPIA download" RetryInterval 60 ErrorAction Stop
do {
Start-Sleep Seconds 5
$Progress = [Math]::Round((100 * ($BitsJob.BytesTransferred / $BitsJob.BytesTotal)),2)
Write-Log Message "Downloaded $Progress`%" Component "DownloadHPIA"
} until ($BitsJob.JobState -in ("Transferred","Error"))
If ($BitsJob.JobState -eq "Error")
{
Write-Log Message "BITS tranfer failed: $($BitsJob.ErrorDescription)" Component "DownloadHPIA" LogLevel 3
UploadLogFilesToAzure
throw
}
Write-Log Message "Download is finished" Component "DownloadHPIA"
Complete-BitsTransfer BitsJob $BitsJob
Write-Log Message "BITS transfer is complete" Component "DownloadHPIA"
}
catch
{
Write-Log Message "Failed to start a BITS transfer for the HPIA: $($_.Exception.Message)" Component "DownloadHPIA" LogLevel 3
UploadLogFilesToAzure
throw
}
##################
## Extract HPIA ##
##################
Write-Log Message "Extracting the HPIA" Component "Analyze"
try
{
$Process = Start-Process FilePath $WorkingDirectory\$HPIAFileName WorkingDirectory $WorkingDirectory ArgumentList "/s /f .\HPIA\ /e" NoNewWindow PassThru Wait ErrorAction Stop
Start-Sleep Seconds 5
If (Test-Path $WorkingDirectory\HPIA\HPImageAssistant.exe)
{
Write-Log Message "Extraction complete" Component "Analyze"
}
Else
{
Write-Log Message "HPImageAssistant not found!" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to extract the HPIA: $($_.Exception.Message)" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
##############################################
## Analyze available BIOS updates with HPIA ##
##############################################
Write-Log Message "Analyzing system for available BIOS updates" Component "Analyze"
try
{
$Process = Start-Process FilePath $WorkingDirectory\HPIA\HPImageAssistant.exe WorkingDirectory $WorkingDirectory ArgumentList "/Operation:Analyze /Category:BIOS /Selection:All /Action:List /Silent /ReportFolder:$WorkingDirectory\Report" NoNewWindow PassThru Wait ErrorAction Stop
If ($Process.ExitCode -eq 0)
{
Write-Log Message "Analysis complete" Component "Analyze"
}
elseif ($Process.ExitCode -eq 256)
{
Write-Log Message "The analysis returned no recommendation. No BIOS update is available at this time" Component "Analyze" LogLevel 2
UploadLogFilesToAzure
Exit 0
}
elseif ($Process.ExitCode -eq 4096)
{
Write-Log Message "This platform is not supported!" Component "Analyze" LogLevel 2
UploadLogFilesToAzure
throw
}
Else
{
Write-Log Message "Process exited with code $($Process.ExitCode). Expecting 0." Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to start the HPImageAssistant.exe: $($_.Exception.Message)" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
# Read the XML report
Write-Log Message "Reading xml report" Component "Analyze"
try
{
$XMLFile = Get-ChildItem Path "$WorkingDirectory\Report" Recurse Include *.xml ErrorAction Stop
If ($XMLFile)
{
Write-Log Message "Report located at $($XMLFile.FullName)" Component "Analyze"
try
{
[xml]$XML = Get-Content Path $XMLFile.FullName ErrorAction Stop
$Recommendation = $xml.HPIA.Recommendations.BIOS.Recommendation
If ($Recommendation)
{
$CurrentBIOSVersion = $Recommendation.TargetVersion
$ReferenceBIOSVersion = $Recommendation.ReferenceVersion
$DownloadURL = "https://" + $Recommendation.Solution.Softpaq.Url
$SoftpaqFileName = $DownloadURL.Split('/')[-1]
Write-Log Message "Current BIOS version is $CurrentBIOSVersion" Component "Analyze"
Write-Log Message "Recommended BIOS version is $ReferenceBIOSVersion" Component "Analyze"
Write-Log Message "Softpaq download URL is $DownloadURL" Component "Analyze"
}
Else
{
Write-Log Message "Failed to find a BIOS recommendation in the XML report" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to parse the XML file: $($_.Exception.Message)" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
Else
{
Write-Log Message "Failed to find an XML report." Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to find an XML report: $($_.Exception.Message)" Component "Analyze" LogLevel 3
UploadLogFilesToAzure
throw
}
###############################
## Download the BIOS softpaq ##
###############################
Write-Log Message "Downloading the Softpaq" Component "DownloadBIOSUpdate"
try
{
$ExistingBitsJob = Get-BitsTransfer Name "$SoftpaqFileName" AllUsers ErrorAction SilentlyContinue
If ($ExistingBitsJob)
{
Write-Log Message "An existing BITS tranfer was found. Cleaning it up." Component "DownloadBIOSUpdate" LogLevel 2
Remove-BitsTransfer BitsJob $ExistingBitsJob
}
$BitsJob = Start-BitsTransfer Source $DownloadURL Destination $WorkingDirectory\$SoftpaqFileName Asynchronous DisplayName "$SoftpaqFileName" Description "BIOS update download" RetryInterval 60 ErrorAction Stop
do {
Start-Sleep Seconds 5
$Progress = [Math]::Round((100 * ($BitsJob.BytesTransferred / $BitsJob.BytesTotal)),2)
Write-Log Message "Downloaded $Progress`%" Component "DownloadBIOSUpdate"
} until ($BitsJob.JobState -in ("Transferred","Error"))
If ($BitsJob.JobState -eq "Error")
{
Write-Log Message "BITS tranfer failed: $($BitsJob.ErrorDescription)" Component "DownloadBIOSUpdate" LogLevel 3
UploadLogFilesToAzure
throw
}
Write-Log Message "Download is finished" Component "DownloadBIOSUpdate"
Complete-BitsTransfer BitsJob $BitsJob
Write-Log Message "BITS transfer is complete" Component "DownloadBIOSUpdate"
}
catch
{
Write-Log Message "Failed to start a BITS transfer for the BIOS update: $($_.Exception.Message)" Component "DownloadBIOSUpdate" LogLevel 3
UploadLogFilesToAzure
throw
}
#########################
## Extract BIOS Update ##
#########################
Write-Log Message "Extracting the BIOS Update" Component "ExtractBIOSUpdate"
$BIOSUpdateDirectoryName = $SoftpaqFileName.Split('.')[0]
try
{
$Process = Start-Process FilePath $WorkingDirectory\$SoftpaqFileName WorkingDirectory $WorkingDirectory ArgumentList "/s /f .\$BIOSUpdateDirectoryName\ /e" NoNewWindow PassThru Wait ErrorAction Stop
Start-Sleep Seconds 5
$HpFirmwareUpdRec = Get-ChildItem Path $WorkingDirectory Include HpFirmwareUpdRec.exe Recurse ErrorAction SilentlyContinue
$HPBIOSUPDREC = Get-ChildItem Path $WorkingDirectory Include HPBIOSUPDREC.exe Recurse ErrorAction SilentlyContinue
If ($HpFirmwareUpdRec)
{
$BIOSExecutable = $HpFirmwareUpdRec
}
ElseIf ($HPBIOSUPDREC)
{
$BIOSExecutable = $HPBIOSUPDREC
}
Else
{
Write-Log Message "BIOS update executable not found!" Component "ExtractBIOSUpdate" LogLevel 3
UploadLogFilesToAzure
throw
}
Write-Log Message "Extraction complete" Component "ExtractBIOSUpdate"
}
catch
{
Write-Log Message "Failed to extract the softpaq: $($_.Exception.Message)" Component "ExtractBIOSUpdate" LogLevel 3
UploadLogFilesToAzure
throw
}
#############################
## Check for BIOS password ##
#############################
try
{
$SetupPwd = (Get-CimInstance Namespace ROOT\HP\InstrumentedBIOS ClassName HP_BIOSPassword Filter "Name='Setup Password'" ErrorAction Stop).IsSet
If ($SetupPwd -eq 1)
{
Write-Log Message "The BIOS has a password set" Component "BIOSPassword"
$BIOSPasswordSet = $true
}
Else
{
Write-Log Message "No password has been set on the BIOS" Component "BIOSPassword"
}
}
catch
{
Write-Log Message "Unable to determine if a BIOS password has been set: $($_.Exception.Message)" Component "BIOSPassword" LogLevel 3
UploadLogFilesToAzure
throw
}
##########################
## Create password file ##
##########################
If ($BIOSPasswordSet)
{
Write-Log Message "Creating an encrypted password file" Component "BIOSPassword"
$HpqPswd = Get-ChildItem Path $WorkingDirectory Include HpqPswd.exe Recurse ErrorAction SilentlyContinue
If ($HpqPswd)
{
try
{
$Process = Start-Process FilePath $HpqPswd.FullName WorkingDirectory $WorkingDirectory ArgumentList "-p""$BIOSPassword"" -f.\password.bin -s" NoNewWindow PassThru Wait ErrorAction Stop
Start-Sleep Seconds 5
If (Test-Path $WorkingDirectory\password.bin)
{
Write-Log Message "File successfully created" Component "BIOSPassword"
}
Else
{
Write-Log Message "Encrypted password file could not be found!" Component "BIOSPassword" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to create an encrypted password file: $($_.Exception.Message)" Component "BIOSPassword" LogLevel 3
UploadLogFilesToAzure
throw
}
}
else
{
Write-Log Message "Failed to locate HP password encryption utility!" Component "BIOSPassword" LogLevel 3
UploadLogFilesToAzure
throw
}
}
###########################
## Stage the BIOS update ##
###########################
Write-Log Message "Staging BIOS firmware update" Component "BIOSFlash"
try
{
If ($BIOSPasswordSet)
{
$Process = Start-Process FilePath "$WorkingDirectory\$BIOSUpdateDirectoryName\$BIOSExecutable" WorkingDirectory $WorkingDirectory ArgumentList "-s -p.\password.bin -f.\$BIOSUpdateDirectoryName -r -b" NoNewWindow PassThru Wait ErrorAction Stop
}
Else
{
$Process = Start-Process FilePath "$WorkingDirectory\$BIOSUpdateDirectoryName\$BIOSExecutable" WorkingDirectory $WorkingDirectory ArgumentList "-s -f.\$BIOSUpdateDirectoryName -r -b" NoNewWindow PassThru Wait ErrorAction Stop
}
If ($Process.ExitCode -eq 3010)
{
Write-Log Message "The update has been staged. The BIOS will be updated on restart" Component "BIOSFlash"
}
Else
{
Write-Log Message "An unexpected exit code was returned: $($Process.ExitCode)" Component "BIOSFlash" LogLevel 3
UploadLogFilesToAzure
throw
}
}
catch
{
Write-Log Message "Failed to stage BIOS update: $($_.Exception.Message)" Component "BIOSFlash" LogLevel 3
UploadLogFilesToAzure
throw
}
Write-Log Message "This BIOS update run is complete. Have a nice day!" Component "Completion"
UploadLogFilesToAzure

Windows 10 Upgrades – Dealing with Safeguard ID 25178825 (Conexant ISST Driver)

I saw a tweet recently from Madhu Sanke where he had deployed updated Conexant ISST drivers to his environment to release devices from the Safeguard ID 25178825 which at the time of writing still prevents devices trying to upgrade to 2004 or 20H2.

You can read more about the issue here, but around September/October 2020 timeframe, newer drivers became available that are not affected by this Safeguard and updating the drivers will release a device from this hold.

Madhu took the approach of downloading the drivers from the Microsoft Update Catalog and packaging them with a script wrapper for the install. In researching this myself, I found that there are more than one driver available and different models will take different drivers, so I decided to write a little C# program that will update a device using Windows Update directly instead.

The program simply connects to Windows Update online, checks if a newer driver version is available for the Conexant ISST audio driver (listed under ‘Conexant – MEDIA – ‘ in the MS update catalog), and downloads and installs it.

The executable can then be deployed as is using a product like Microsoft Endpoint Configuration Manager.

For environments where software updates are deployed with Configuration Manager / WSUS, the program will check if registry keys have been set preventing access to Windows Update online and temporarily open them. It will restore the previous settings after updating.

The program also logs to the Temp directory.

On my HP laptops, the driver in question is this guy:

This one has been updated and is no longer affected by the Safeguard.

You can download the C# executable from my GitHub repo, or you can clone the solution in Visual Studio and compile your own executable. Just remember to run the program in SYSTEM context or with administrative privilege on the client as it’s installing a driver.

A reboot is usually required after installation and the driver can display a small one-time toast notification like this:

If your devices are managed by Windows Update for Business, you may see a notification from there as well, depending on your configuration of course.

After deploying the driver update to affected devices, expect to see them being released from the Safeguard after telemetry has run. You can use my PowerBI reports to help with reporting on Safeguards in your environment.

Thanks again to Madhu for the inspiration!

Real world notes: In-place OS upgrade on Server 2012 R2 ConfigMgr distribution points

In my MEMCM primary site I had several distribution points that were still running Windows Server 2012 R2, so I decided to run an in-place OS upgrade on them to bring them up to Server 2019. After reading the MS Docs, I concluded this is a supported scenario and things would go smoothly. I was quite wrong, however!

The OS upgrade itself went very well. I scripted the process to automate it and deployed it via MEMCM and the servers upgraded in around 1-1.5 hours. However, after the upgrade I found two big issues on each server:

  • The ConfigMgr client was broken – specifically WMI. The SMS Agent host (ccmexec) service was not running and would not start. Digging in the logs, I could see that ccmrepair.exe was running and attempting to fix the client, however it was repeatedly failing with the following error:

MSI: Setup was unable to compile the file DiscoveryStatus.mof. The error code is 80041002

  • The root\SCCMDP namespace was missing from WMI. This essentially breaks the distribution point role as no packages can be updated or distributed to it and content validation will fail

It’s quite possible something on the servers contributed to these issues happening, but they were actually fairly clean boxes – physical HP servers running only the ConfigMgr client, some HP software, antivirus and a few agents such as MMA and Azure agents. When I contacted Microsoft support they suggested to perform a site reset, but when you have many servers to upgrade that isn’t viable. They also wouldn’t update the Docs as they couldn’t reproduce the issues internally even though I’m not the only one to report them.

Anyway, I’m documenting here what I did to fix it and the scripts I used.

To fix the broken ConfigMgr client I did the following:

  1. Compile the mof file %program files%\Microsoft Policy Platform\ExtendedStatus.mof
  2. Stop any ccmrepair.exe or ccmsetup.exe process so they don’t hinder step 3
  3. Run the ‘Configuration Manager Health Evaluation’ scheduled task (ccmeval.exe) a couple of times. This will self-remediate the WMI issues.

To fix the broken DP role:

  1. Compile the mof file ..\SMS_DP$\sms\bin\smsdpprov.mof (this restores the missing WMI namespace)
  2. Query the ConfigMgr database to find the list of packages distributed to the distribution point
  3. Run some PowerShell code to restore the packages as instances in the root\SCCMDP:SMS_PackagesInContLib WMI class
  4. Run the Content Validation scheduled task to revalidate the content and remove any errors in the console

For the latter, I don’t take any credit in my script below as I simply used and expanded something I found here. Note both scripts must be run as administrator and the second script requires read access to the ConfigMgr database.

Repair ConfigMgr client script

# Complile mof file
Write-host "Compiling ExtendedStatus.mof file"
mofcomp "C:\Program Files\Microsoft Policy Platform\ExtendedStatus.mof"

# Stop processes that might hinder ccmeval
If (Get-Process -Name ccmrepair -ErrorAction SilentlyContinue)
{
    Write-host "Found ccmrepair process. Stopping it..."
    Stop-Process -Name ccmrepair -Force
    Start-Sleep -Seconds 5
}
If (Get-Process -Name ccmsetup -ErrorAction SilentlyContinue)
{
    Write-host "Found ccmsetup process. Stopping it..."
    Stop-Process -Name ccmsetup -Force
    Start-Sleep -Seconds 5
}

# Run ccmeval to self-remediate the broken WMI
Write-host "Starting Configuration Manager Health Evaluation to repair the ConfigMgr client"
Start-ScheduledTask -TaskName "Configuration Manager Health Evaluation" -TaskPath "\Microsoft\Configuration Manager"
$P = Get-Process -Name ccmeval
$P.WaitForExit()
Start-Sleep -Seconds 5
Write-host "Starting Configuration Manager Health Evaluation one more time"
Start-ScheduledTask -TaskName "Configuration Manager Health Evaluation" -TaskPath "\Microsoft\Configuration Manager"
$P = Get-Process -Name ccmeval
$P.WaitForExit()

# Open the logs to verify it was successful
Start-Process -FilePath C:\Windows\CCM\CMTrace.exe -ArgumentList "C:\Windows\ccmsetup\Logs\ccmsetup-ccmeval.log"
Start-Process -FilePath C:\Windows\CCM\CMTrace.exe -ArgumentList "C:\Windows\CCM\Logs\CcmEval.log"

Repair DP role script

# MEMCM database params
$script:dataSource = 'MyConfigMgrDatabaseServer' 
$script:database = 'MyConfigMgrDatabase'

# Function to query SQL server...must have db_datareader role for current user context
function Get-SQLData {
    [CmdletBinding()]
    param($Query)
    $connectionString = "Server=$dataSource;Database=$database;Integrated Security=SSPI;"
    $connection = New-Object -TypeName System.Data.SqlClient.SqlConnection
    $connection.ConnectionString = $connectionString
    $connection.Open()
    
    $command = $connection.CreateCommand()
    $command.CommandText = $Query
    $reader = $command.ExecuteReader()
    $table = New-Object -TypeName 'System.Data.DataTable'
    $table.Load($reader)
    
    # Close the connection
    $connection.Close()
    
    return $Table
}

Try
{
    Get-CimClass -Namespace root\SCCMDP -ErrorAction Stop
    Write-host "WMI namespace root\SCCMDP is present"
    Return
}
Catch
{
   If ($_.Exception.NativeErrorCode -eq "InvalidNamespace")
   {
        Write-host "WMI namespace root\SCCMDP is missing" -ForegroundColor Red
        Write-host "Performing remediations..."
   }
   else 
   {
        $_
        Return    
   }
}

# Compile DP mof file
Write-host "Compiling smsdpprov.mof file"
mofcomp "$(Get-SmbShare | where {$_.Name -match "SMS_DP"} | Select -ExpandProperty Path)\sms\bin\smsdpprov.mof"

# Query database for DP package info
$Server = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\SMS\DP" -Name Server | Select -ExpandProperty Server
$Query = @"
declare @ServerName varchar (50) = '$Server'
select
  dp.PackageID
, case
 when p.ShareName <> '' 
  then '\\' + @ServerName + '\' + p.ShareName
 else ''
  end ShareName
, 'Set-WmiInstance -Path ''ROOT\SCCMDP:SMS_PackagesInContLib'' -Arguments @{PackageID="'
  + dp.PackageID + '";PackageShareLocation="' +
 case
  when p.ShareName <> '' 
   then '\\' + @ServerName + '\' + p.ShareName
  else ''
   end + '"}' PowershellCommand
from v_DistributionPoint dp
inner join v_Package p on p.PackageID = dp.PackageID
where dp.ServerNALPath like '[[]"Display=\\' + @ServerName + '%'
"@
Write-host "Querying database for DP package info"
Try
{
    $Results = Get-SQLData -Query $Query  -ErrorAction Stop
    Write-host "Found $($results.rows.count) packages"
}
Catch
{
    $_
    Return
}

# Run the POSH code to add the package back into WMI
Write-host "Restoring WMI instances to ROOT\SCCMDP:SMS_PackagesInContLib"
If ($Results)
{
    Foreach ($result in $results)
    {
        $Scriptblock = [scriptblock]::Create($Result.PowershellCommand)
        Try
        {
            $null = Invoke-Command -ScriptBlock $Scriptblock -ErrorAction Stop
        }
        Catch
        {
            $_
        }
    }
}
Else
{
    Throw "No package info found for this DP"
    Return
}

# Start the content validation task...may take some time
Write-host "Starting Content validation. Check smsdpmon.log for progress"
Start-ScheduledTask -TaskPath "\Microsoft\Configuration Manager" -TaskName "Content Validation"

Forcing a Full Hardware Inventory Report to be Sent Immediately on a ConfigMgr Client

Sometimes you might want to force a ConfigMgr client to send a full hardware inventory report immediately for whatever reason. Typically you would simply clean out the WMI instance for the InventoryAction then trigger the schedule. But sometimes there may already be a scheduled action in the queue, for example the hardware inventory cycle has been triggered on the normal schedule but it runs with randomization so it doesn’t run immediately when it’s triggered. In this case, you get a message in the InventoryAgent.log that looks like this:

Inventory: Message [Type=InventoryAction, ActionID={00000000-0000-0000-0000-000000000001}, Report=Delta] already in queue. Message ignored.

It ignores your request if there’s already a request queued.

You can still force it to run immediately though by clearing the queue. To do this you can simply delete the InventoryAgent queue folder but you can’t do this while the SMS Agent host service is running, you have to stop the service first.

Below is a script that will attempt to trigger a full a HWI report and check the InventoryAgent.log to see if the request was ignored – if so, it clears the queue and tries again.

# Invoke a full (resync) HWI report
$Instance = Get-CimInstance -NameSpace ROOT\ccm\InvAgt -Query "SELECT * FROM InventoryActionStatus WHERE InventoryActionID='{00000000-0000-0000-0000-000000000001}'"
$Instance | Remove-CimInstance
Invoke-CimMethod -Namespace ROOT\ccm -ClassName SMS_Client -MethodName TriggerSchedule -Arguments @{ sScheduleID = "{00000000-0000-0000-0000-000000000001}"}
Start-Sleep -Seconds 5

# Check InventoryAgent log for ignored message
$Log = "$env:SystemRoot\CCM\Logs\InventoryAgent.Log"
$LogEntries = Select-String –Path $Log –SimpleMatch "{00000000-0000-0000-0000-000000000001}" | Select -Last 1
If ($LogEntries -match "already in queue. Message ignored.")
{
    # Clear the message queue
    # WARNING: This restarts the SMS Agent host service
    Stop-Service -Name CcmExec -Force
    Remove-Item -Path C:\Windows\CCM\ServiceData\Messaging\EndpointQueues\InventoryAgent -Recurse -Force -Confirm:$false
    Start-Service -Name CcmExec

    # Invoke a full (resync) HWI report
    Start-Sleep -Seconds 5
    $Instance = Get-CimInstance -NameSpace ROOT\ccm\InvAgt -Query "SELECT * FROM InventoryActionStatus WHERE InventoryActionID='{00000000-0000-0000-0000-000000000001}'"
    $Instance | Remove-CimInstance
    Invoke-CimMethod -Namespace ROOT\ccm -ClassName SMS_Client -MethodName TriggerSchedule -Arguments @{ sScheduleID = "{00000000-0000-0000-0000-000000000001}"}
}

ConfigMgr Client TCP Port Tester

This is a little tool I created for testing the required TCP ports on SCCM client systems. It will check that the required inbound ports are open and that the client can communicate to its management point, distribution point and software update point on the required ports. It also includes a custom port checker for testing any inbound or outbound port.

The default ports are taken from the Microsoft documentation, but these can be edited in the case that non-default ports are being used, or additional ports need to be tested.

The tool does not currently test UDP ports.

Requirements

  • Windows 8.1 + / Windows Server 2012 R2 +
  • PowerShell 5
  • .Net Framework 4.6.2 minimum

Download

Download from the Technet Gallery.

Usage

To use the tool, extract the ZIP file, right-click the ‘ConfigMgr Client TCP Port Tester.ps1′ and run with PowerShell.

Checking Inbound Ports

Select Local Ports in the drop-down box and click GO to test the required inbound ports.

Checking Outbound Ports

Select the destination in the drop-down box (ie management point, distribution point, software update point).

Enter the destination server name if not populated by the defaults and click GO. The tool will test ICMP connectivity first, then port connectivity.

Custom Port Checking

To test a custom port, select Custom Port Test from the drop-down box. Enter the port number, direction (ie Inbound or Outbound) and destination (Outbound only). Click Add to add the test to the grid. You can add several tests. Click GO.

Adding Default Servers

You can pre-populate server names by editing the Defaults.xml file found in the defaults directory. For example, to add a default management point:

<ConfigMgr_Port_Tester>
  <ServerDefaults>
    <ManagementPoint>
      <Value>SCCMMP01</Value>
    </ManagementPoint>

Editing / Adding Default Ports

You can also edit, add or remove the default ports in the Defaults.xml file. For example, to add port 5985 in the default local port list:

<PortDefaults>
  <LocalPorts>
    <Port Name="80" Purpose="HTTP Communication"/>
    <Port Name="443" Purpose="HTTPS Communication"/>
    <Port Name="445" Purpose="SMB"/>
    <Port Name="135" Purpose="Remote Assistance / Remote Desktop"/>
    <Port Name="2701" Purpose="Remote Control"/>
    <Port Name="3389" Purpose="Remote Assistance / Remote Desktop"/>
    <Port Name="5985" Purpose="WinRM"/>
  </LocalPorts>

Source Code

Source code can be found in my GitHub repo.

Create Collections for SCCM Client Installation Failures by Error Code

Ok, so in a perfect SCCM world you would never have any SCCM client installation failures and this post would be totally unnecessary. But in the real world, you are very likely to have a number of systems that fail to install the SCCM client and the reasons can be many.

To identify such systems, it can be helpful to create collections for some of the common client installation failure codes so you can easily see and report on which type of installation failures you are experiencing and the number of systems affected.

To identify the installation failure error codes you have in your environment for Windows systems, run the following SQL query against the SCCM database:

select 
	Count(cdr.Name) as 'Count',
	cdr.CP_LastInstallationError as 'Last Installation Error Code'
from v_CombinedDeviceResources cdr
where
	cdr.CP_LastInstallationError is not null
	and cdr.IsClient = 0
	and cdr.DeviceOS like '%Windows%'
group by cdr.CP_LastInstallationError
order by 'Count' desc
Client installation error counts

Next simply create a collection for each error code using the following WQL query, changing the LastInstallationError value to the relevant error code:

select 
    SYS.ResourceID,
    SYS.ResourceType,
    SYS.Name,
    SYS.SMSUniqueIdentifier,
    SYS.ResourceDomainORWorkgroup,
    SYS.Client 
from SMS_R_System as SYS 
Inner Join SMS_CM_RES_COLL_SMS00001 as COL on SYS.ResourceID = COL.ResourceID  
Where COL.LastInstallationError = 53 
And (SYS.Client = 0  Or SYS.Client is null)

Error codes are all fine and dandy, but unless you have an error code database in your head you’ll want to translate those codes to friendly descriptions. To do that, I use a PowerShell function I created that pulls the description from the SrsResources.dll which you can find in any SCCM console installation. There’s more than one way to translate error codes though – see my blog post here. Better yet, create yourself an error code SQL database which you can join to in your SQL queries and is super useful for reporting purposes – see my post here.

Anyway, once you’ve translated the error codes, you can name your collections with them for easy reference:

Client installation failure collections

Now comes the hard part – figuring out how to fix those errors and working through all the affected systems 😬

Get ConfigMgr Client Versions with PowerShell

When upgrading your ConfigMgr site, or installing an update that creates a new ConfigMgr client package, it can be helpful to monitor the rollout of the new client version in your environment.

I put together this PowerShell function which uses my New-WPFMessageBox function to graphically display the count and percentage of client versions in the ConfigMgr site. The data comes from a SQL query, so you’ll need minimum db_datareader access to your ConfigMgr database with your logged-in account, as well as the New-WPFMessageBox function.

By default, it shows only active systems, but you can include inactive systems by checking the box.

img1

img2

Function Get-CMClientVersions {
# Requires the "New-WPFMessageBox" function available at https://gist.github.com/SMSAgentSoftware/0c0eee98a673b6ac34f5215ea6841beb
# Requires minimum "db_datareader" SQL role in the ConfigMgr database
# Usage: Get-CMClientVersions -SQLServer "SQLServer" -Database "Database"
[CmdletBinding()]
Param
(
[Parameter(Mandatory=$True,Position=0)]
[string]$SQLServer,
[Parameter(Mandatory=$True,Position=1)]
[string]$Database
)
# Define SQL Queries
$WithInactiveQuery = "
Select
sys.Client_Version0 as 'Client Version',
count(sys.ResourceID) as 'Count',
cast(count(sys.ResourceID) * 100.0 / (
select
count(*)
from v_R_System
inner join dbo.v_CH_ClientSummary on v_R_System.ResourceID = dbo.v_CH_ClientSummary.ResourceID
) as numeric(36,2))as 'Percent'
from v_R_System sys
inner join dbo.v_CH_ClientSummary ch on sys.ResourceID = ch.ResourceID
Group by sys.Client_Version0
Order by sys.Client_Version0 desc
"
$ActiveQuery = "
Select
sys.Client_Version0 as 'Client Version',
count(sys.ResourceID) as 'Count',
cast(count(sys.ResourceID) * 100.0 / (
select
count(*)
from v_R_System
inner join dbo.v_CH_ClientSummary on v_R_System.ResourceID = dbo.v_CH_ClientSummary.ResourceID
where dbo.v_CH_ClientSummary.ClientActiveStatus = 1
) as numeric(36,2))as 'Percent'
from v_R_System sys
inner join dbo.v_CH_ClientSummary ch on sys.ResourceID = ch.ResourceID
where ch.ClientActiveStatus = 1
Group by sys.Client_Version0
Order by sys.Client_Version0 desc
"
# Create a datagrid
$DataGrid = New-Object System.Windows.Controls.DataGrid
$DataGrid.IsReadOnly = $True
$DataGrid.FontSize = 20
$DataGrid.CanUserAddRows = "False"
$DataGrid.GridLinesVisibility = "None"
$DataGrid.FontFamily = "Candara"
$DataGrid.Margin = 5
$DataGrid.Padding = 5
$DataGrid.BorderThickness = 0
$DataGrid.HorizontalAlignment = "Stretch"
$DataGrid.VerticalAlignment = "Stretch"
$DataGrid.Width = "NaN"
$DataGrid.Height = "NaN"
# Create a data source and bind it to the datagrid
$DataContext = New-Object System.Collections.ObjectModel.ObservableCollection[Object]
$Binding = New-Object System.Windows.Data.Binding
$Binding.Path = "[0].DefaultView"
$Binding.Mode = [System.Windows.Data.BindingMode]::OneWay
$Binding.Source = $DataContext
[void]$DataGrid.SetBinding([System.Windows.Controls.DataGrid]::ItemsSourceProperty,$Binding)
# Create a checkbox for optionall including inactive systems in the results
$CheckBox = New-Object System.Windows.Controls.CheckBox
$CheckBox.Content = "Include inactive systems"
$CheckBox.FontSize = 16
$CheckBox.FontFamily = "Candara"
$CheckBox.HorizontalAlignment = "Center"
$CheckBox.VerticalContentAlignment = "Center"
$CheckBox.Padding = 5
$CheckBox.Add_Checked({
Invoke-SQLQuery DataContext $DataContext Query $WithInactiveQuery SQLServer $SQLServer Database $Database
})
$CheckBox.Add_UnChecked({
Invoke-SQLQuery DataContext $DataContext Query $ActiveQuery SQLServer $SQLServer Database $Database
})
# Create a stackpanel
$StackPanel = New-Object System.Windows.Controls.StackPanel
$StackPanel.AddChild($CheckBox)
$StackPanel.AddChild($DataGrid)
# Function to query SQL Server
function Invoke-SQLQuery
{
[CmdletBinding()]
Param
(
[string]$SQLServer,
[string]$Database,
[Parameter(ValueFromPipeline=$true)]
[string]$Query,
[int]$ConnectionTimeout = 5,
[int]$CommandTimeout = 120,
$DataContext
)
# Define connection string
$connectionString = "Server=$SQLServer;Database=$Database;Integrated Security=SSPI;Connection Timeout=$ConnectionTimeout"
# Open the connection
Try
{
$connection = New-Object TypeName System.Data.SqlClient.SqlConnection
$connection.ConnectionString = $connectionString
$connection.Open()
# Execute the query
$command = $connection.CreateCommand()
$command.CommandText = $Query
$command.CommandTimeout = $CommandTimeout
$reader = $command.ExecuteReader()
}
Catch
{
$CustomError = $_.Exception.Message
If ($CustomError -match '"')
{
$CustomError = $CustomError.Replace('"',"'")
}
$Params = @{
Title = "Error"
TitleFontSize = "20"
TitleFontWeight = "Bold"
TitleBackground = "Red"
TitleTextForeground = "White"
Content = "Could not connect to SQL Server:&#10;&#10;$CustomError"
FontFamily = "Candara"
Sound = 'Windows User Account Control'
}
New-WPFMessageBox @Params
Return
}
# Load results to a data table
$table = New-Object TypeName 'System.Data.DataTable'
$table.Load($reader)
# Close the connection
$connection.Close()
# Return result
If ($DataContext[0] -eq $null)
{
$DataContext.Add($Table)
}
Else
{
$DataContext[0] = $table
}
}
# Display the message box
$Params = @{
Title = "ConfigMgr Client Versions"
TitleFontSize = "28 "
TitleBackground = "LightSeaGreen"
FontFamily = "Candara"
Content = $StackPanel
OnLoaded = {Invoke-SQLQuery DataContext $DataContext Query $ActiveQuery SQLServer $SQLServer Database $Database}
}
New-WPFMessageBox @Params
}

Forcing a ConfigMgr Client to Send a New CCMEval Report

In order to maintain a healthy ConfigMgr environment, it is important to know that your clients have successfully run the Configuration Manager Health Evaluation task and reported the results to the Site server.  Sometimes you will find a number of systems that have not reported any health status to the Site server.  In the Devices node of the ConfigMgr Console, you will find “No Results” for the Client Check Result, and the Client Check Detail tab displays nothing, even though the system may be active.

capture

capture

To identify the list of active systems that either have not reported health evaluation results, or have failed the evaluation, I use the following SQL query:


select
sys.Name0 as 'Computer Name',
sys.User_Name0 as 'User Name',
summ.ClientStateDescription,
case when summ.ClientActiveStatus = 0 then 'Inactive'
 when summ.ClientActiveStatus = 1 then 'Active'
 end as 'ClientActiveStatus',
summ.LastActiveTime,
case when summ.IsActiveDDR = 0 then 'Inactive'
 when summ.IsActiveDDR = 1 then 'Active'
 end as 'IsActiveDDR',
case when summ.IsActiveHW = 0 then 'Inactive'
 when summ.IsActiveHW = 1 then 'Active'
 end as 'IsActiveHW',
case when summ.IsActiveSW = 0 then 'Inactive'
 when summ.IsActiveSW = 1 then 'Active'
 end as 'IsActiveSW',
case when summ.ISActivePolicyRequest = 0 then 'Inactive'
 when summ.ISActivePolicyRequest = 1 then 'Active'
 end as 'ISActivePolicyRequest',
case when summ.IsActiveStatusMessages = 0 then 'Inactive'
 when summ.IsActiveStatusMessages = 1 then 'Active'
 end as 'IsActiveStatusMessages',
summ.LastOnline,
summ.LastDDR,
summ.LastHW,
summ.LastSW,
summ.LastPolicyRequest,
summ.LastStatusMessage,
summ.LastHealthEvaluation,
case when LastHealthEvaluationResult = 1 then 'Not Yet Evaluated'
 when LastHealthEvaluationResult = 2 then 'Not Applicable'
 when LastHealthEvaluationResult = 3 then 'Evaluation Failed'
 when LastHealthEvaluationResult = 4 then 'Evaluated Remediated Failed'
 when LastHealthEvaluationResult = 5 then 'Not Evaluated Dependency Failed'
 when LastHealthEvaluationResult = 6 then 'Evaluated Remediated Succeeded'
 when LastHealthEvaluationResult = 7 then 'Evaluation Succeeded'
 end as 'Last Health Evaluation Result',
case when LastEvaluationHealthy = 1 then 'Pass'
 when LastEvaluationHealthy = 2 then 'Fail'
 when LastEvaluationHealthy = 3 then 'Unknown'
 end as 'Last Evaluation Healthy',
case when summ.ClientRemediationSuccess = 1 then 'Pass'
 when summ.ClientRemediationSuccess = 2 then 'Fail'
 else ''
 end as 'ClientRemediationSuccess',
summ.ExpectedNextPolicyRequest
from v_CH_ClientSummary summ
inner join v_R_System sys on summ.ResourceID = sys.ResourceID
where summ.LastEvaluationHealthy in (2,3)
and summ.ClientActiveStatus = 1
order by summ.LastActiveTime Desc

In most cases where the evaluation status reports “Unknown” by this query, you will find that the client has actually run the health evaluation task, it just hasn’t reported the results to the management point for some reason.  I published a PowerShell script previously that lets you view the current health evaluation status on any remote computer by reading the CCMEvalReport.xml file – you can find the script here.

For these “Unknown” status systems, however, I want to force the client to send a health evaluation report to its management point, so I prepared the following PowerShell script to do that.  It can run either against the local computer, or a remote computer.  Admin rights are required on the target system, and if running against the local computer the script must be run as administrator.

The script simply sets the SendAlways flag for CCMEval reports in the registry to “TRUE”, triggers the CM Health Evaluation task to run, waits for it to finish, then changes the SendAlways flag back to “FALSE”.  When the CCMEval program runs with the SendAlways flag set, it will always send the report to the management point even if the client health status has not changed since the last report.

You can verify that from the CcmEval.log on the client:

capture

Within a few minutes you should find that the status for that system has been updated in the ConfigMgr Console with the health evaluation results.

To run the script against the local machine, run PowerShell as administrator and simply do:


Send-CCMEvalReport

To run against a remote computer:


Send-CCMEvalReport -ComputerName PC001

The script also supports verbose output:

Send-CCMEvalReport -ComputerName PC001 -Verbose

capture

Here’s the full code:

Send-CCMEvalReport.ps1


[CmdletBinding()]
    param(
        [Parameter(Mandatory=$False)]
        [String]$ComputerName = $env:COMPUTERNAME
        )

# Code to set 'SendAlways' in registry
$SendAlways = {
    Param($Value)
    $Path = "HKLM:\Software\Microsoft\CCM\CcmEval"

    Try
    {
        $null = New-ItemProperty -Path $Path -Name 'SendAlways' -Value $Value -Force -ErrorAction Stop
    }
    Catch
    {
        $_
    }
}

# Run against local computer
If ($ComputerName -eq $env:COMPUTERNAME)
{
    If (!([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator"))
    {
        Write-Warning "This cmdlet must be run as administrator against the local machine!"
        Return
    }

    Write-Verbose "Enabling 'SendAlways' in registry"
    $Result = Invoke-Command -ArgumentList "TRUE" -ScriptBlock $SendAlways 

    If (!$Result.Exception)
    {
        Write-Verbose "Triggering CM Health Evaluation task"
        Invoke-Command -ScriptBlock { schtasks /Run /TN "Microsoft\Configuration Manager\Configuration Manager Health Evaluation" /I }

        Write-Verbose "Waiting for ccmeval to finish"
        do {} while (Get-process -Name ccmeval -ErrorAction SilentlyContinue)

        Write-Verbose "Disabling 'SendAlways' in registry"
        Invoke-Command -ArgumentList "FALSE" -ScriptBlock $SendAlways
    }
    Else
    {
        Write-Error $($Result.Exception.Message)
    }
}
# Run against remote computer
Else
{
    Write-Verbose "Enabling 'SendAlways' in registry"
    $Result = Invoke-Command -ComputerName $ComputerName -ArgumentList "TRUE" -ScriptBlock $SendAlways

    If (!$Result.Exception)
    {
        Write-Verbose "Triggering CM Health Evaluation task"
        Invoke-Command -ComputerName $ComputerName -ScriptBlock { schtasks /Run /TN "Microsoft\Configuration Manager\Configuration Manager Health Evaluation" /I }

        Write-Verbose "Waiting for ccmeval to finish"
        do {} while (Get-process -ComputerName $ComputerName -Name ccmeval -ErrorAction SilentlyContinue)

        Write-Verbose "Disabling 'SendAlways' in registry"
        Invoke-Command -ComputerName $ComputerName -ArgumentList "FALSE" -ScriptBlock $SendAlways
    }
    Else
    {
        Write-Error $($Result.Exception.Message)
    }
}