Public/Reports/Get-CameraReport.ps1

function Get-CameraReport {
    <#
    .SYNOPSIS
        Gets detailed information for all cameras in the current site
    .DESCRIPTION
        This camera report is a rewrite of the previous camera report available in version 20.3.450930
        and earlier. This version makes use of runspaces to significantly improve the time taken to generate
        the report, and includes several additional properties in each row that were not present in prior versions.
 
        Use Get-CameraReport to get a snapshot in time of most of the configuration properties for each camera and
        the state of cameras in the VMS. For example, you can use this report to see the current video retention for
        each camera, or whether any cameras are in media overflow or have communication errors.
 
        You might pipe this report to CSV and open the report in Excel, group by Recording Server, and get a sense for
        the number of cameras per Recording Server or create a pivot table on cameras and their resolutions and frame rates.
 
        Thanks to the 60+ columns of information provided for each camera in this report, there are innumerable ways in which
        you could use it to help identify problems, maintain a consistent configuration, or report data to other departments.
 
        Column Notes
 
        - State: This is returned by Get-ItemState and represents the Milestone Event Server's understanding of the state of the camera. The physical camera may be responding to ping but still be 'Not Responding' in Milestone due to other issues.
        - MediaOverflow: This means the Recording Server is unable to keep up with the amount of video being recorded, and is dropping video frames from one or more cameras. Any time this value is true, you should investigate Recording Server performance.
        - GpsCoordinates: This value will be returned as 'Unknown' if not set. The value comes from the Camera object's GisPoint property.
        - MediaDatabaseBegin: This timestamp represents the time and date, in UTC, of the first image in the media database. If your video retention is 30 days, you should expect this timestamp to be at least 30 days ago under normal circumstances.
        - MediaDatabaseEnd: This timestamp represents the time and date, in UTC, of the last recorded image in the media database. If your camera is set to record always, this timestamp should be very recent.
        - UsedSpaceInBytes: This value represents the disk space utilization for the camera on the Recording Server to which it belongs, including data in any live and archive storage to which the camera is assigned.
        - PercentRecordedOneWeek: This optional value is only set when you include the -IncludeRecordingStats parameter. It may take significantly longer to generate the report when including this information. The value will be the percentage of time over the previous 7 days for which recordings exist. When recording on motion, in an environment with 50% motion, this value will normally be a little higher than 50% to account for pre and post recording buffers.
        - LastModified: This should represent the last configuration change made to the camera.
        - Password: This optional value is only set when you include the -IncludePasswords parameter.
        - HTTPSEnabled: For cameras supporting HTTPS and where HTTPS is enabled at the hardware level in Management Client, you will see the value "YES". Otherwise, you will see the value "NO".
        - Firmware: This value may NOT be accurate. For example, if the firmware has been upgraded since the camera was added to Milestone, the value may still reflect the old firmware version. This is only because Milestone may only update our record of the firmware when the camera is added or replaced in Management Client. You might consider this value as an indication that the firmware is 'at least' X.Y for example.
        - DriverNumber: This represents the Milestone ID number for the device driver used for this camera. When adding hardware using Import-HardwareCsv or Add-Hardware, this is the number Milestone would expect when specifying which driver to use.
        - DriverRevision: This is an internal value representing the driver number for the specific device driver within the currently installed device pack.
        - RecordingPath: This value is always relative to the Recording Server. If you see a drive letter F:, for example, then it refers to the F: drive on the Recording Server to which the camera is assigned.
        - Snapshot: This optional value is only set when you include the -IncludeSnapshots parameter. The value will be an [image] object and it will be up to you to decide what to do with the value once you have it. Exporting to CSV for example will result in the string [drawing.image] rather than an actual image or image data.
    .EXAMPLE
        PS C:\> Get-CameraReport | Export-Csv -Path .\camera-report.csv -NoTypeInformation
        Gets a camera report and saves the contents to a CSV file in the current folder.
    .EXAMPLE
        PS C:\> Get-CameraReport -IncludeRecordingStats | Export-Csv -Path .\camera-report.csv -NoTypeInformation
        Gets a camera report with an additional call to Get-CameraRecordingStats which will provide the percentage of time
        recorded over the last 7 days for each camera. This uses runspaces to help process the requests in parallel and uses
        Get-SequenceData under the surface to gather the recording sequence data to determine how much time out of the last
        week was spent recording for each camera.
    .NOTES
        If you see the value "NotAvailable" it typically means the camera was disabled, there were no recordings available, the state of the camera is not "Responding", or the property value requested, such as Resolution, does not exist in the stream properties for the camera.
    #>

    [CmdletBinding()]
    param (
        # Specifies one or more Recording Servers from which to generate a camera report
        [Parameter(ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.RecordingServer[]]
        $RecordingServer,

        # Include plain text hardware passwords in the report
        [Parameter()]
        [switch]
        $IncludePlainTextPasswords,

        # Specifies that disabled cameras should be excluded from the results
        [Parameter()]
        [switch]
        $IncludeDisabled,

        # Specifies that a live JPEG snapshot should be requested for each camera. The snapshot will be attached as an [drawing.image] object
        # and it is up to you to decide how to process these images. Some 3rd party modules may allow you to pipe the camera report to Excel
        # for example. Alternatively, you may manually process each row of the report to save the images to disk in a path of your choice.
        [Parameter()]
        [switch]
        $IncludeSnapshots,

        # Specifies the height of the snapshots included if the IncludeSnapshots switch is provided. The default is 300 pixels high. The width will be determined based on the aspect ratio of the original image.
        # A value of 0 will result in snapshots at the original image resolution of the live stream at the time the report is run.
        [Parameter()]
        [ValidateRange(0, [int]::MaxValue)]
        [int]
        $SnapshotHeight = 300,

        # Specifies that you want to add the percentage of time recorded in the past week for each camera
        [Parameter()]
        [switch]
        $IncludeRecordingStats
    )

    begin {
        $initialSessionState = [initialsessionstate]::CreateDefault()
        foreach ($functionName in @('Get-StreamProperties', 'ConvertFrom-StreamUsage', 'Get-ValueDisplayName', 'ConvertFrom-Snapshot', 'ConvertFrom-GisPoint')) {
            $definition = Get-Content Function:\$functionName -ErrorAction Stop
            $sessionStateFunction = [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new($functionName, $definition)
            $initialSessionState.Commands.Add($sessionStateFunction)
        }
        $runspacepool = [runspacefactory]::CreateRunspacePool(1, ([int]$env:NUMBER_OF_PROCESSORS + 1), $initialSessionState, $Host)
        $runspacepool.Open()
        $threads = New-Object System.Collections.Generic.List[pscustomobject]
        $processDevice = {
            param(
                [VideoOS.Platform.Messaging.ItemState[]]$States,
                [VideoOS.Platform.ConfigurationItems.RecordingServer]$RecordingServer,
                [hashtable]$VideoDeviceStatistics,
                [hashtable]$CurrentDeviceStatus,
                [hashtable]$RecordingStats,
                [hashtable]$StorageTable,
                [VideoOS.Platform.ConfigurationItems.Hardware]$Hardware,
                [VideoOS.Platform.ConfigurationItems.Camera]$Camera,
                [bool]$IncludePasswords,
                [bool]$IncludeSnapshots,
                [int]$SnapshotHeight
            )
            $cameraEnabled = $Hardware.Enabled -and $Camera.Enabled
            $streamUsages = $Camera | Get-Stream -All
            $liveStreamName = $streamUsages | Where-Object LiveDefault | ConvertFrom-StreamUsage
            $recordStreamName = $streamUsages | Where-Object Record | ConvertFrom-StreamUsage
            $liveStreamSettings = $Camera | Get-StreamProperties -StreamName $liveStreamName
            $recordedStreamSettings = if ($liveStreamName -eq $recordStreamName) { $liveStreamSettings } else { $Camera | Get-StreamProperties -StreamName $recordStreamName }

            $motionDetection = $Camera.MotionDetectionFolder.MotionDetections[0]
            $hardwareSettings = $Hardware | Get-HardwareSetting
            $playbackInfo = @{ Begin = 'NotAvailable'; End = 'NotAvailable'}
            if ($cameraEnabled -and $camera.RecordingEnabled) {
                $tempPlaybackInfo = $Camera | Get-PlaybackInfo -ErrorAction Ignore -WarningAction Ignore
                if ($null -ne $tempPlaybackInfo) {
                    $playbackInfo = $tempPlaybackInfo
                }
            }
            $driver = $Hardware | Get-HardwareDriver
            $password = ''
            if ($IncludePasswords) {
                try {
                    $password = $Hardware | Get-HardwarePassword -ErrorAction Ignore
                }
                catch {
                    $password = $_.Message
                }
            }
            $cameraState = if ($cameraEnabled) { $States | Where-Object { $_.FQID.ObjectId -eq $Camera.Id } | Select-Object -ExpandProperty State } else { 'NotAvailable' }
            $cameraStatus = $CurrentDeviceStatus.$($RecordingServer.Id).CameraDeviceStatusArray | Where-Object DeviceId -eq $Camera.Id
            $statistics = $VideoDeviceStatistics.$($RecordingServer.Id) | Where-Object DeviceId -eq $Camera.Id
            $currentLiveFps = $statistics | Select-Object -ExpandProperty VideoStreamStatisticsArray | Where-Object LiveStreamDefault | Select-Object -ExpandProperty FPS -First 1
            $currentRecFps = $statistics | Select-Object -ExpandProperty VideoStreamStatisticsArray | Where-Object RecordingStream | Select-Object -ExpandProperty FPS -First 1
            $expectedRetention = New-Timespan -Minutes ($StorageTable.$($Camera.RecordingStorage) | ForEach-Object { $_; $_.ArchiveStorageFolder.ArchiveStorages } | Sort-Object RetainMinutes -Descending | Select-Object -First 1 -ExpandProperty RetainMinutes)
            
            $snapshot = $null
            if ($IncludeSnapshots -and $cameraEnabled -and $cameraStatus.Started -and $cameraState -eq 'Responding') {
                $snapshot = $Camera | Get-Snapshot -Live -Quality 100 -ErrorAction Ignore | ConvertFrom-Snapshot
                if ($SnapshotHeight -ne 0 -and $null -ne $snapshot) {
                    $snapshot = $snapshot | Resize-Image -Height $SnapshotHeight -DisposeSource
                }
            }
            elseif (!$IncludeSnapshots) {
                $snapshot = 'NotRequested'
            }
            
            [pscustomobject]@{
                Name = $Camera.Name
                Channel = $Camera.Channel
                Enabled = $cameraEnabled
                State = $cameraState
                MediaOverflow = if ($cameraEnabled)  { $cameraStatus.ErrorOverflow } else { 'NotAvailable' }
                DbRepairInProgress = if ($cameraEnabled)  { $cameraStatus.DbRepairInProgress } else { 'NotAvailable' }
                DbWriteError = if ($cameraEnabled)  { $cameraStatus.ErrorWritingGop } else { 'NotAvailable' }
                GpsCoordinates = $Camera | ConvertFrom-GisPoint
                MediaDatabaseBegin = $playbackInfo.Begin
                MediaDatabaseEnd = $playbackInfo.End
                UsedSpaceInBytes = if ($cameraEnabled) { $statistics | Select-Object -ExpandProperty UsedSpaceInBytes } else { 'NotAvailable' }
                PercentRecordedOneWeek = if ($cameraEnabled -and $RecordingStats.$($Camera.Id).PercentRecorded -is [double]) { $RecordingStats.$($Camera.Id).PercentRecorded } else { 'NotAvailable' }

                LastModified = $Camera.LastModified
                Id = $Camera.Id
                HardwareName = $Hardware.Name
                Address = $Hardware.Address
                Username = $Hardware.UserName
                Password = $password
                HTTPSEnabled = if ($null -ne $hardwareSettings.HTTPSEnabled) { $hardwareSettings.HTTPSEnabled.ToUpper() } else { 'NO' }
                MAC = $hardwareSettings.MacAddress
                Firmware = $hardwareSettings.FirmwareVersion
                Model = $Hardware.Model
                Driver = $driver.Name
                DriverNumber = $driver.Number.ToString()
                DriverRevision = $driver.DriverRevision
                HardwareId = $Hardware.Id
                RecorderName = $RecordingServer.Name
                RecorderUri = $RecordingServer.WebServerUri
                RecorderId = $RecordingServer.Id

                ConfiguredLiveResolution = Get-ValueDisplayName -PropertyList $liveStreamSettings -PropertyName 'Resolution', 'StreamProperty' #GetResolution -PropertyList $liveStreamSettings
                ConfiguredLiveCodec = Get-ValueDisplayName -PropertyList $liveStreamSettings -PropertyName 'Codec'
                ConfiguredLiveFPS = Get-ValueDisplayName -PropertyList $liveStreamSettings -PropertyName 'FPS', 'Framerate'
                LiveMode = $streamUsages | Where-Object LiveDefault | Select-Object -ExpandProperty LiveMode
                ConfiguredRecordResolution = Get-ValueDisplayName -PropertyList $recordedStreamSettings -PropertyName 'Resolution', 'StreamProperty' #GetResolution -PropertyList $recordedStreamSettings
                ConfiguredRecordCodec = Get-ValueDisplayName -PropertyList $recordedStreamSettings -PropertyName 'Codec'
                ConfiguredRecordFPS = Get-ValueDisplayName -PropertyList $recordedStreamSettings -PropertyName 'FPS', 'Framerate'

                CurrentLiveResolution = if ($cameraEnabled) { $statistics | Select-Object -ExpandProperty VideoStreamStatisticsArray | Where-Object LiveStreamDefault | Select-Object -ExpandProperty ImageResolution -First 1 | Foreach-Object { "$($_.Width)x$($_.Height)" } } else { 'NotAvailable' }
                CurrentLiveFPS = if ($cameraEnabled -and $currentLiveFps -is [double]) { [math]::Round($currentLiveFps, 1) } else { 'NotAvailable' }
                CurrentLiveBPS = if ($cameraEnabled) { $statistics | Select-Object -ExpandProperty VideoStreamStatisticsArray | Where-Object LiveStreamDefault | Select-Object -ExpandProperty BPS -First 1 } else { 'NotAvailable' }
                CurrentRecordedResolution = if ($cameraEnabled) { $statistics | Select-Object -ExpandProperty VideoStreamStatisticsArray | Where-Object RecordingStream | Select-Object -ExpandProperty ImageResolution -First 1 | Foreach-Object { "$($_.Width)x$($_.Height)" } } else { 'NotAvailable' }
                CurrentRecordedFPS = if ($cameraEnabled -and $currentRecFps -is [double]) { [math]::Round($currentRecFps, 1) } else { 'NotAvailable' }
                CurrentRecordedBPS = if ($cameraEnabled) { $statistics | Select-Object -ExpandProperty VideoStreamStatisticsArray | Where-Object RecordingStream | Select-Object -ExpandProperty BPS -First 1 } else { 'NotAvailable' }

                RecordingEnabled = $Camera.RecordingEnabled
                RecordKeyframesOnly = $Camera.RecordKeyframesOnly
                RecordOnRelatedDevices = $Camera.RecordOnRelatedDevices
                PrebufferEnabled = $Camera.PrebufferEnabled
                PrebufferSeconds = $Camera.PrebufferSeconds
                PrebufferInMemory = $Camera.PrebufferInMemory

                RecordingStorageName = $StorageTable.$($Camera.RecordingStorage).Name
                RecordingPath = [io.path]::Combine($StorageTable.$($Camera.RecordingStorage).DiskPath, $StorageTable.$($Camera.RecordingStorage).Id)
                ExpectedRetention = $expectedRetention
                ActualRetention = if ($playbackInfo.Begin -is [string]) { 'NotAvailable' } else { [datetime]::UtcNow - $playbackInfo.Begin }
                MeetsRetentionPolicy = if ($playbackInfo.Begin -is [string]) { 'NotAvailable' } else { ([datetime]::UtcNow - $playbackInfo.Begin) -ge $expectedRetention }

                MotionEnabled = $motionDetection.Enabled
                MotionKeyframesOnly = $motionDetection.KeyframesOnly
                MotionProcessTime = $motionDetection.ProcessTime
                MotionSensitivityMode = if ($motionDetection.ManualSensitivityEnabled) { 'Manual' } else { 'Automatic' }
                MotionManualSensitivity = $motionDetection.ManualSensitivity
                MotionMetadataEnabled = $motionDetection.GenerateMotionMetadata
                MotionExcludeRegions = if ($motionDetection.UseExcludeRegions) { 'Yes' } else { 'No' }
                MotionHardwareAccelerationMode = $motionDetection.HardwareAccelerationMode

                PrivacyMaskEnabled = $Camera.PrivacyProtectionFolder.PrivacyProtections[0].Enabled

                Snapshot = $snapshot
            }
        }
    }

    process {
        $progressParams = @{
            Activity = 'Camera Report'
            CurrentOperation = ''
            Status = 'Preparing to run report'
            PercentComplete = 0
            Completed = $false
        }
        if ($null -eq $RecordingServer) {
            Write-Verbose "Getting a list of all recording servers on $((Get-ManagementServer).Name)"
            $progressParams.CurrentOperation = 'Getting Recording Servers'
            Write-Progress @progressParams
            $RecordingServer = Get-RecordingServer
        }

        Write-Verbose 'Getting the current state of all cameras'
        $progressParams.CurrentOperation = 'Calling Get-ItemState'
        Write-Progress @progressParams
        $itemState = Get-ItemState -ErrorAction Stop

        Write-Verbose 'Discovering all cameras and retrieving status and statistics'

        try {
            $progressParams.CurrentOperation = 'Calling Get-VideoDeviceStatistics on all responding recording servers'
            Write-Progress @progressParams
            $respondingRecordingServers = $RecordingServer.Id | Where-Object { $id = $_; $id -in $itemState.FQID.ObjectId -and ($itemState | Where-Object { $id -eq $_.FQID.ObjectId }).State -eq 'Server Responding' }
            $respondingCameras = ($itemState | Where-Object { $_.FQID.Kind -eq [videoos.platform.kind]::Camera }).FQID.ObjectId
            $videoDeviceStatistics = Get-VideoDeviceStatistics -AsHashtable -RecordingServerId $respondingRecordingServers -RunspacePool $runspacepool
            
            $progressParams.CurrentOperation = 'Calling Get-CurrentDeviceStatus on all responding recording servers'
            Write-Progress @progressParams
            $currentDeviceStatus = Get-CurrentDeviceStatus -AsHashtable -RecordingServerId $respondingRecordingServers -RunspacePool $runspacepool
            $recordingStats = @{}
            if ($IncludeRecordingStats) {
                $progressParams.CurrentOperation = "Retrieving 7 days of recording stats for $($respondingCameras.Count) cameras using Get-CameraRecordingStats"
                Write-Progress @progressParams
                $recordingStats = Get-CameraRecordingStats -Id $respondingCameras -AsHashTable -RunspacePool $runspacepool
            }

            $progressParams.CurrentOperation = 'Adding camera information requests to the queue'
            Write-Progress @progressParams
            $storageTable = @{}
            foreach ($rs in $RecordingServer) {
                $rs.StorageFolder.Storages | Foreach-Object {
                    $_.FillChildren('StorageArchive')
                    $storageTable.$($_.Path) = $_
                }
                foreach ($hw in $rs | Get-Hardware) {
                    foreach ($cam in $hw | Get-Camera) {
                        if (!$IncludeDisabled -and -not ($cam.Enabled -and $hw.Enabled)) {
                            continue
                        }
                        $ps = [powershell]::Create()
                        $ps.RunspacePool = $runspacepool
                        $asyncResult = $ps.AddScript($processDevice).AddParameters(@{
                            State = $itemState
                            RecordingServer = $rs
                            VideoDeviceStatistics = $videoDeviceStatistics
                            CurrentDeviceStatus = $currentDeviceStatus
                            RecordingStats = $recordingStats
                            StorageTable = $storageTable
                            Hardware = $hw
                            Camera = $cam
                            IncludePasswords = $IncludePlainTextPasswords
                            IncludeSnapshots = $IncludeSnapshots
                            SnapshotHeight = $SnapshotHeight
                        }).BeginInvoke()
                        $threads.Add([pscustomobject]@{
                            PowerShell = $ps
                            Result = $asyncResult
                        })
                    }
                }
            }

            if ($threads.Count -eq 0) {
                return
            }
            $progressParams.CurrentOperation = 'Processing'
            $completedThreads = New-Object System.Collections.Generic.List[pscustomobject]
            $totalDevices = $threads.Count
            while ($threads.Count -gt 0) {
                $progressParams.PercentComplete = ($totalDevices - $threads.Count) / $totalDevices * 100
                $progressParams.Status = "Processed $($totalDevices - $threads.Count) out of $totalDevices cameras"
                Write-Progress @progressParams
                foreach ($thread in $threads) {
                    if ($thread.Result.IsCompleted) {
                        $thread.PowerShell.EndInvoke($thread.Result)
                        $thread.PowerShell.Dispose()
                        $completedThreads.Add($thread)
                    }
                }
                $completedThreads | Foreach-Object { [void]$threads.Remove($_)}
                $completedThreads.Clear()
                if ($threads.Count -eq 0) {
                    break;
                }
                Start-Sleep -Seconds 1
            }
        }
        finally {
            if ($threads.Count -gt 0) {
                Write-Warning "Stopping $($threads.Count) running PowerShell instances. This may take a minute. . ."
                foreach ($thread in $threads) {
                    $thread.PowerShell.Dispose()
                }
            }
            $runspacepool.Close()
            $runspacepool.Dispose()
            $progressParams.Completed = $true
            Write-Progress @progressParams
        }
    }
}