MilestonePSTools.psm1


Import-Module "$PSScriptRoot\bin\MilestonePSTools.dll"
enum VmsTaskState {
    Completed
    Error
    Idle
    InProgress
    Success
    Unknown
}

class VmsTaskResult {
    [int] $Progress
    [string] $Path
    [string] $ErrorCode
    [string] $ErrorText
    [VmsTaskState] $State

    VmsTaskResult () {}

    VmsTaskResult([VideoOS.ConfigurationApi.ClientService.ConfigurationItem] $InvokeItem) {
        foreach ($p in $InvokeItem.Properties) {
            switch ($p.ValueType) {
                'Progress' {
                    $this.($p.Key) = [int]$p.Value
                }
                'Tick' {
                    $this.($p.Key) = [bool]::Parse($p.Value)
                }
                default {
                    $this.($p.Key) = $p.Value
                }
            }
        }
    }
}

class VmsHardwareScanResult : VmsTaskResult {
    [uri] $HardwareAddress
    [string] $UserName
    [string] $Password
    [bool] $MacAddressExistsGlobal
    [bool] $MacAddressExistsLocal
    [bool] $HardwareScanValidated
    [string] $MacAddress
    [string] $HardwareDriverPath

    # Property hidden so that this type can be cleanly exported to CSV or something
    # without adding a column with a complex object in it.
    hidden [VideoOS.Platform.ConfigurationItems.RecordingServer] $RecordingServer

    VmsHardwareScanResult() {}

    VmsHardwareScanResult([VideoOS.ConfigurationApi.ClientService.ConfigurationItem] $InvokeItem) {
        foreach ($p in $InvokeItem.Properties) {
            switch ($p.ValueType) {
                'Progress' {
                    $this.($p.Key) = [int]$p.Value
                }
                'Tick' {
                    $this.($p.Key) = [bool]::Parse($p.Value)
                }
                default {
                    $this.($p.Key) = $p.Value
                }
            }
        }
    }
}

# Contains the output from the script passed to LocalJobRunner.AddJob, in addition to any errors thrown in the script if present.
class LocalJobResult {
    [object[]] $Output
    [System.Management.Automation.ErrorRecord[]] $Errors
}

# Contains the IAsyncResult object returned by PowerShell.BeginInvoke() as well as the PowerShell instance we need to
class LocalJob {
    [System.Management.Automation.PowerShell] $PowerShell
    [System.IAsyncResult] $Result
}

# Centralizes the complexity of running multiple commands/scripts at a time and receiving the results, including errors, when they complete.
class LocalJobRunner : IDisposable {
    hidden [System.Management.Automation.Runspaces.RunspacePool] $RunspacePool
    hidden [System.Collections.Generic.List[LocalJob]] $Jobs
    [timespan] $JobPollingInterval = (New-Timespan -Seconds 1)

    # Default constructor creates an underlying runspace pool with a max size matching the number of processors
    LocalJobRunner () {
        $this.Initialize($env:NUMBER_OF_PROCESSORS)
    }

    # Optionally you may manually specify a max size for the underlying runspace pool.
    LocalJobRunner ([int]$MaxSize) {
        $this.Initialize($MaxSize)
    }

    hidden [void] Initialize([int]$MaxSize) {
        $this.Jobs = New-Object System.Collections.Generic.List[LocalJob]
        $iss = [initialsessionstate]::CreateDefault()
        $iss.ImportPSModule((Get-Module MipSdkRedist).Path)
        $iss.ImportPSModule((Get-Module MilestonePSTools).Path)
        $this.RunspacePool = [runspacefactory]::CreateRunspacePool(1, $MaxSize, $iss, (Get-Host))
        $this.RunspacePool.Open()
    }

    # Accepts a scriptblock and a set of parameters. A new powewershell instance will be created, attached to a runspacepool, and the results can be collected later in a call to ReceiveJobs.
    [LocalJob] AddJob([scriptblock]$scriptblock, [hashtable]$parameters) {
        $parameters = if ($null -eq $parameters) { $parameters = @{} } else { $parameters }
        $shell = [powershell]::Create()
        $shell.RunspacePool = $this.RunspacePool
        $asyncResult = $shell.AddScript($scriptblock).AddParameters($parameters).BeginInvoke()
        $job = [LocalJob]@{
            PowerShell = $shell
            Result     = $asyncResult
        }
        $this.Jobs.Add($job)
        return $job
    }

    # Returns the output from specific jobs
    [LocalJobResult[]] ReceiveJobs([LocalJob[]]$localJobs) {
        $completedJobs = $localJobs | Where-Object { $_.Result.IsCompleted }
        $completedJobs | Foreach-Object { $this.Jobs.Remove($_) }
        $results = $completedJobs | Foreach-Object {
            [LocalJobResult]@{
                Output = $_.PowerShell.EndInvoke($_.Result)
                Errors = $_.PowerShell.Streams.Error
            }

            $_.PowerShell.Dispose()
        }
        return $results
    }

    # Returns the output from any completed jobs in an object that also includes any errors if present.
    [LocalJobResult[]] ReceiveJobs() {
        return $this.ReceiveJobs($this.Jobs)
    }

    # Block until all jobs have completed. The list of jobs will be polled on an interval of JobPollingInterval, which is 1 second by default.
    [void] Wait() {
        $this.Wait($this.Jobs)
    }

    # Block until all jobs have completed. The list of jobs will be polled on an interval of JobPollingInterval, which is 1 second by default.
    [void] Wait([LocalJob[]]$jobList) {
        while ($jobList.Result.IsCompleted -contains $false) {
            Start-Sleep -Seconds $this.JobPollingInterval.TotalSeconds
        }
    }

    # Returns $true if there are any jobs available to be received using ReceiveJobs. Use to implement your own polling strategy instead of using Wait.
    [bool] HasPendingJobs() {
        return ($this.Jobs.Count -gt 0)
    }

    # Make sure to dispose of this class so that the underlying runspace pool gets disposed.
    [void] Dispose() {
        $this.Jobs.Clear()
        $this.RunspacePool.Close()
        $this.RunspacePool.Dispose()
    }
}

class VmsCameraStreamConfig {
    [string] $Name
    [string] $DisplayName
    [bool] $Enabled
    [bool] $LiveDefault
    [string] $LiveMode
    [bool] $Recorded
    [guid] $StreamReferenceId
    [hashtable] $Settings
    [hashtable] $ValueTypeInfo
    hidden [VideoOS.Platform.ConfigurationItems.Camera] $Camera
}

class VmsStreamDeviceStatus : VideoOS.Platform.SDK.Proxy.Status2.MediaStreamDeviceStatusBase {
    [string] $DeviceName
    [string] $DeviceType
    [string] $RecorderName
    [guid]   $RecorderId
    [bool]   $Motion

    VmsStreamDeviceStatus () {}
    VmsStreamDeviceStatus ([VideoOS.Platform.SDK.Proxy.Status2.MediaStreamDeviceStatusBase]$status) {
        $this.DbMoveInProgress = $status.DbMoveInProgress
        $this.DbRepairInProgress = $status.DbRepairInProgress
        if ($null -ne $status.DeviceId) {
            $this.DeviceId = $status.DeviceId
        }
        $this.Enabled = $status.Enabled
        $this.Error = $status.Error
        $this.ErrorNoConnection = $status.ErrorNoConnection
        $this.ErrorNotLicensed = $status.ErrorNotLicensed
        $this.ErrorOverflow = $status.ErrorOverflow
        $this.ErrorWritingGop = $status.ErrorWritingGop
        $this.IsChange = $status.IsChange
        $this.Recording = $status.Recording
        $this.Started = $status.Started
        if ($null -ne $status.Time) {
            $this.Time = $status.Time
        }
        if ($null -ne $status.Motion) {
            $this.Motion = $status.Motion
        }
    }
}

enum ViewItemImageQuality {
    Full      = 100
    SuperHigh = 101
    High      = 102
    Medium    = 103
    Low       = 104
}

enum ViewItemPtzMode {
    Default
    ClickToCenter
    VirtualJoystick
}

class VmsCameraViewItemProperties {
    # These represent the default XProtect Smart Client camera view item properties
    [guid]   $Id                            = [guid]::NewGuid()
    [guid]   $SmartClientId                 = [guid]::NewGuid()
    [guid]   $CameraId                      = [guid]::Empty
    [string] $CameraName                    = [string]::Empty
    [nullable[int]] $Shortcut               = $null
    [guid]   $LiveStreamId                  = [guid]::Empty
    [ValidateRange(100, 104)]
    [int]    $ImageQuality                  = [ViewItemImageQuality]::Full
    [int]    $Framerate                     = 0
    [bool]   $MaintainImageAspectRatio      = $true
    [bool]   $UseDefaultDisplaySettings     = $true
    [bool]   $ShowTitleBar                  = $true
    [bool]   $KeepImageQualityWhenMaximized = $false
    [bool]   $UpdateOnMotionOnly            = $false
    [bool]   $SoundOnMotion                 = $false
    [bool]   $SoundOnEvent                  = $false
    [int]    $SmartSearchGridWidth          = 0
    [int]    $SmartSearchGridHeight         = 0
    [string] $SmartSearchGridMask           = [string]::Empty
    [ValidateRange(0, 2)]
    [int]    $PointAndClickMode             = [ViewItemPtzMode]::Default
}

class VmsViewGroupAcl {
    [VideoOS.Platform.ConfigurationItems.Role] $Role
    [string] $Path
    [hashtable] $SecurityAttributes
}

class RoleNameTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        if ($inputData -is [VideoOS.Platform.ConfigurationItems.Role] -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is [VideoOS.Platform.ConfigurationItems.Role])) {
            return $inputData
        }
        try {
            if ($inputData.Role) {
                $inputData = $inputData.Role
            }
            if ($inputData -is [string] -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is [string])) {
                $items = Get-VmsRole -Name $inputData
                if ($inputData -is [string]) {
                    return $items[0]
                }
                return $items
            } else {
                throw "Unexpected type '$($inputData.GetType().FullName)'"
            }
        } catch {
            throw $_.Exception
        }
    }

    [string] ToString() {
        return "[RoleNameTransformAttribute()]"
    }
}

class SecurityNamespaceTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        if ($inputData -is [guid] -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is [guid])) {
            return $inputData
        }
        if ($inputData.SecurityNamespace) {
            $inputData = $inputData.SecurityNamespace
        }
        if ($inputData -is [string] -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is [string])) {
            $result = [string[]]@()
            foreach ($value in $inputData) {
                $id = [guid]::Empty
                if (-not [guid]::TryParse($value, [ref]$id)) {
                    try {
                        $id = (Get-VmsRole -RoleType UserDefined | Select-Object -First 1).ChangeOverallSecurityPermissions().SecurityNamespaceValues[$value]
                    }
                    catch {
                        $id = $value
                    }
                    $result += $id
                } else {
                    $result += $id
                }
            }
            if ($result.Count -eq 0) {
                throw "No matching SecurityNamespace(s) found."
            }
            if ($inputData -is [string]) {
                return $result[0]
            }
            return $result
        }
        throw "Unexpected type '$($inputData.GetType().FullName)'"
    }

    [string] ToString() {
        return "[SecurityNamespaceTransformAttribute()]"
    }
}

class TimeProfileNameTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        if ($inputData -is [VideoOS.Platform.ConfigurationItems.TimeProfile] -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is [VideoOS.Platform.ConfigurationItems.TimeProfile])) {
            return $inputData
        }
        try {
            if ($inputData.TimeProfile) {
                $inputData = $inputData.TimeProfile
            }
            if ($inputData -is [string] -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is [string])) {
                $items = $inputData | Foreach-Object {
                    (Get-VmsManagementServer).TimeProfileFolder.TimeProfiles | Where-Object Name -eq $_
                }
                if ($items.Count -eq 0) {
                    throw "No matching TimeProfile(s) found."
                }
                if ($inputData -is [string]) {
                    return $items[0]
                } else {
                    return $items
                }
            } else {
                throw "Unexpected type '$($inputData.GetType().FullName)'"
            }
        } catch {
            throw $_.Exception
        }
    }

    [string] ToString() {
        return "[TimeProfileNameTransformAttribute()]"
    }
}

class RecorderNameTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        if ($inputData -is [VideoOS.Platform.ConfigurationItems.RecordingServer] -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is [VideoOS.Platform.ConfigurationItems.RecordingServer])) {
            return $inputData
        }
        try {
            if ($inputData.RecordingServer) {
                $inputData = $inputData.RecordingServer
            }
            if ($inputData -is [string] -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is [string])) {
                $items = $inputData | Foreach-Object {
                    Get-VmsRecordingServer -Name $_
                }
                if ($items.Count -eq 0) {
                    throw "No matching RecordingServer(s) found."
                }
                if ($inputData -is [string]) {
                    return $items[0]
                } else {
                    return $items
                }
            } else {
                throw "Unexpected type '$($inputData.GetType().FullName)'"
            }
        } catch {
            throw $_.Exception
        }
    }

    [string] ToString() {
        return "[RecorderNameTransformAttribute()]"
    }
}

class StorageNameTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        if ($inputData -is [VideoOS.Platform.ConfigurationItems.Storage] -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is [VideoOS.Platform.ConfigurationItems.Storage])) {
            return $inputData
        }
        try {
            if ($inputData.Storage) {
                $inputData = $inputData.Storage
            }
            if ($inputData -is [string] -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is [string])) {
                $items = $inputData | Foreach-Object {
                    Get-VmsRecordingServer | Get-VmsStorage | Where-Object Name -eq $_
                }
                if ($items.Count -eq 0) {
                    throw "No matching storage(s) found."
                }
                return $items
            } else {
                throw "Unexpected type '$($inputData.GetType().FullName)'"
            }
        } catch {
            throw $_.Exception
        }
    }

    [string] ToString() {
        return "[StorageNameTransformAttribute()]"
    }
}

class BooleanTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        if ($inputData -is [bool]) {
            return $inputData
        }
        elseif ($inputData -is [string]) {
            return [bool]::Parse($inputData)
        }
        throw "Unexpected type '$($inputData.GetType().FullName)'"
    }

    [string] ToString() {
        return "[BooleanTransformAttribute()]"
    }
}
function Assert-IsAdmin {
    [CmdletBinding()]
    param (
    )
    
    process {
        $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
        $adminRole = [Security.Principal.WindowsBuiltInRole]::Administrator
        if (-not $principal.IsInRole($adminRole)) {
            throw "Elevation is required. Consider re-launching PowerShell by right-clicking and running as Administrator."
        }
    }
}
function Assert-VmsConnected {
    [CmdletBinding()]
    param (
    )

    process {
        if ($null -eq [MilestonePSTools.Connection.MilestoneConnection]::Instance) {
            $message = 'Not connected to a Management Server.'
            if ($script:Messages) {
                $message = $script:Messages.NotConnectedToAManagementServer
            }
            throw ([VideoOS.Platform.CommunicationMIPException]::new($message))
        }
    }
}
function Assert-VmsVersion {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [Version]
        $MinimumVersion,

        [Parameter()]
        [string]
        $Comment
    )

    process {
        $site = Get-Site
        $currentVersion = [version]$site.Properties['ServerVersion']
        if ($currentVersion -lt $MinimumVersion) {
            $callingFunction = (Get-PSCallStack)[1].Command
            $message = "$callingFunction requires a minimum server version of $MinimumVersion. The current site is running version $currentVersion."
            if (-not [string]::IsNullOrWhiteSpace($Comment)) {
                $message = '{0} Reason: {1}' -f $message, $Comment
            }
            $exception = [notsupportedexception]::new($message)

            $errorParams = @{
                Message = $message
                ErrorId = 'MinVmsVersionNotMet'
                Exception = $exception
                Category = 'NotImplemented'
                RecommendedAction = "Upgrade server to Milestone VMS version $MinimumVersion or later."
            }
            Write-Error @errorParams
        }
    }
}
function Complete-SimpleArgument {
    <#
    .SYNOPSIS
    Implements a simple argument-completer.
    .DESCRIPTION
    This cmdlet is a helper function that implements a basic argument completer
    which matches the $wordToComplete against a set of values that can be
    supplied in the form of a string array, or produced by a scriptblock you
    provide to the function.
    .PARAMETER Arguments
    The original $args array passed from Register-ArgumentCompleter into the
    scriptblock.
    .PARAMETER ValueSet
    An array of strings representing the valid values for completion.
    .PARAMETER Completer
    A scriptblock which produces an array of strings representing the valid values for completion.
    .EXAMPLE
    Register-ArgumentCompleter -CommandName Get-VmsRole -ParameterName Name -ScriptBlock {
        Complete-SimpleArgument $args {(Get-VmsManagementServer).RoleFolder.Roles.Name}
    }
    Registers an argument completer for the Name parameter on the Get-VmsRole
    command. Complete-SimpleArgument cmdlet receives the $args array, and a
    simple scriptblock which returns the names of all roles in the VMS.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [object[]]
        $Arguments,

        [Parameter(Mandatory, Position = 1, ParameterSetName = 'ValuesFromArray')]
        [string[]]
        $ValueSet,

        [Parameter(Mandatory, Position = 1, ParameterSetName = 'ValuesFromScriptBlock')]
        [scriptblock]
        $Completer
    )

    process {
        # Get ValueSet from scriptblock if provided, otherwise use $ValueSet.
        if ($PSCmdlet.ParameterSetName -eq 'ValuesFromScriptBlock') {
            $ValueSet = $Completer.Invoke($Arguments)
        }

        # Trim single/double quotes off of beginning of word if present. If no
        # characters have been provided, set the word to "*" for wildcard matching.
        $wordToComplete = if ([string]::IsNullOrEmpty($Arguments[2])) { '*' } else { $Arguments[2] }
        if ($wordToComplete.StartsWith("'")) {
            $wordToComplete = $wordToComplete.TrimStart("'")
        } elseif ($wordToComplete.StartsWith('"')) {
            $wordToComplete = $wordToComplete.TrimStart('"')
        }

        # Return matching values from ValueSet.
        $ValueSet | Foreach-Object {
            if ($_ -like "$wordToComplete*") {
                if ($_ -like '* *') {
                    "'$_'"
                } else {
                    $_
                }
            }
        }
    }
}
class VmsConfigChildItemSettings {
    [string]    $Name
    [hashtable] $Properties
    [hashtable] $ValueTypeInfo
}

function ConvertFrom-ConfigChildItem {
    [CmdletBinding()]
    [OutputType([VmsConfigChildItemSettings])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [VideoOS.Platform.ConfigurationItems.IConfigurationChildItem]
        $InputObject,

        [Parameter()]
        [switch]
        $RawValues
    )

    process {
        # When we look up display values for raw values, sometimes
        # the raw value matches the value of a valuetypeinfo property
        # like MinValue or MaxValue. We don't want to display "MinValue"
        # as the display value for a setting, so this list of valuetypeinfo
        # entry names should be ignored.
        $ignoredNames = 'MinValue', 'MaxValue', 'StepValue'
        $properties = @{}
        $valueTypeInfos = @{}
        foreach ($key in $InputObject.Properties.Keys) {
            # Sometimes the Keys are the same as KeyFullName and other times
            # they are short, easy to read names. So just in case, we'll test
            # the key by splitting it and seeing how many parts there are. A
            # KeysFullName value looks like 'device:0.0/RecorderMode/75f374ab-8dd2-4fd0-b8f5-155fa730702c'
            $keyParts = $key -split '/', 3
            $keyName = if ($keyParts.Count -gt 1) { $keyParts[1] } else { $key }

            $value = $InputObject.Properties.GetValue($key)
            $valueTypeInfo = $InputObject.Properties.GetValueTypeInfoCollection($key)

            if (-not $RawValues) {
                <#
                  Unless -RawValues was used, we'll check to see if there's a
                  display name available for the value for the current setting.
                  If a ValueTypeInfo entry has a Value matching the raw value,
                  and the Name of that value isn't one of the internal names we
                  want to ignore, we'll replace $value with the ValueTypeInfo
                  Name. Here's a reference ValueTypeInfo table for RecorderMode:
 
                  TranslationId Name Value
                  ------------- ---- -----
                  b9f5c797-ebbf-55ad-ccdd-8539a65a0241 Disabled 0
                  535863a8-2f16-3709-557e-59e2eb8139a7 Continuous 1
                  8226588f-03da-49b8-57e5-ddf8c508dd2d Motion 2
 
                  So if the raw value of RecorderMode is 0, we would return
                  "Disabled" unless the -RawValues switch is used.
                #>


                $friendlyValue = ($valueTypeInfo | Select-Object | Where-Object {
                        $_.Value -eq $value -and $_.Name -notin $ignoredNames
                    }).Name
                if (-not [string]::IsNullOrWhiteSpace($friendlyValue)) {
                    $value = $friendlyValue
                }
            }

            $properties[$keyName] = $value
            $valueTypeInfos[$keyName] = $valueTypeInfo
        }

        [VmsConfigChildItemSettings]@{
            Name          = $InputObject.DisplayName
            Properties    = $properties
            ValueTypeInfo = $valueTypeInfos
        }
    }
}
function ConvertFrom-StreamUsage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.StreamUsageChildItem]
        $StreamUsage
    )

    process {
        $streamName = $StreamUsage.StreamReferenceIdValues.Keys | Where-Object {
            $StreamUsage.StreamReferenceIdValues.$_ -eq $StreamUsage.StreamReferenceId
        }
        Write-Output $streamName
    }
}
function ConvertTo-ConfigItemPath {
    [CmdletBinding()]
    [OutputType([videoos.platform.proxy.ConfigApi.ConfigurationItemPath])]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string[]]
        $Path
    )

    process {
        foreach ($p in $Path) {
            try {
                [videoos.platform.proxy.ConfigApi.ConfigurationItemPath]::new($p)
            } catch {
                Write-Error -Message "The value '$p' is not a recognized configuration item path format." -Exception $_.Exception
            }
        }
    }
}
function ConvertTo-Sid {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string]
        $AccountName,

        [Parameter()]
        [string]
        $Domain
    )

    process {
        try {
            if ($AccountName -match '^\[BASIC\]\\(?<username>.+)$') {
                $sid = (Get-VmsManagementServer).BasicUserFolder.BasicUsers | Where-Object Name -eq $Matches.username | Select-Object -ExpandProperty Sid
                if ($sid) {
                    $sid
                } else {
                    throw "No basic user found matching '$AccountName'"
                }
            } else {
                [System.Security.Principal.NTAccount]::new($Domain, $AccountName).Translate([System.Security.Principal.SecurityIdentifier]).Value
            }
        } catch [System.Security.Principal.IdentityNotMappedException] {
            Write-Error -ErrorRecord $_
        }
    }
}
function ConvertTo-Uri {
    <#
    .SYNOPSIS
    Accepts an IPv4 or IPv6 address and converts it to an http or https URI
 
    .DESCRIPTION
    Accepts an IPv4 or IPv6 address and converts it to an http or https URI. IPv6 addresses need to
    be wrapped in square brackets when used in a URI. This function is used to help normalize data
    into an expected URI format.
 
    .PARAMETER IPAddress
    Specifies an IPAddress object of either Internetwork or InternetworkV6.
 
    .PARAMETER UseHttps
    Specifies whether the resulting URI should use https as the scheme instead of http.
 
    .PARAMETER HttpPort
    Specifies an alternate port to override the default http/https ports.
 
    .EXAMPLE
    '192.168.1.1' | ConvertTo-Uri
    #>

    [CmdletBinding()]
    [OutputType([uri])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [IPAddress]
        $IPAddress,

        [Parameter()]
        [switch]
        $UseHttps,

        [Parameter()]
        [int]
        $HttpPort = 80
    )

    process {
        $builder = [uribuilder]::new()
        $builder.Scheme = if ($UseHttps) { 'https' } else { 'http' }
        $builder.Host = if ($IPAddress.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) {
            "[$IPAddress]"
        }
        else {
            $IPAddress
        }
        $builder.Port = $HttpPort
        Write-Output $builder.Uri
    }
}
function Copy-ConfigurationItem {
    [CmdletBinding()]
    param (
        [parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [pscustomobject]
        $InputObject,
        [parameter(Mandatory, Position = 1)]
        [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]
        $DestinationItem
    )

    process {
        if (!$DestinationItem.ChildrenFilled) {
            Write-Verbose "$($DestinationItem.DisplayName) has not been retrieved recursively. Retrieving child items now."
            $DestinationItem = $DestinationItem | Get-ConfigurationItem -Recurse -Sort
        }

        $srcStack = New-Object -TypeName System.Collections.Stack
        $srcStack.Push($InputObject)
        $dstStack = New-Object -TypeName System.Collections.Stack
        $dstStack.Push($DestinationItem)

        Write-Verbose "Configuring $($DestinationItem.DisplayName) ($($DestinationItem.Path))"
        while ($dstStack.Count -gt 0) {
            $dirty = $false
            $src = $srcStack.Pop()
            $dst = $dstStack.Pop()

            if (($src.ItemCategory -ne $dst.ItemCategory) -or ($src.ItemType -ne $dst.ItemType)) {
                Write-Error "Source and Destination ConfigurationItems are different"
                return
            }

            if ($src.EnableProperty.Enabled -ne $dst.EnableProperty.Enabled) {
                Write-Verbose "$(if ($src.EnableProperty.Enabled) { "Enabling"} else { "Disabling" }) $($dst.DisplayName)"
                $dst.EnableProperty.Enabled = $src.EnableProperty.Enabled
                $dirty = $true
            }

            $srcChan = $src.Properties | Where-Object { $_.Key -eq "Channel"} | Select-Object -ExpandProperty Value
            $dstChan = $dst.Properties | Where-Object { $_.Key -eq "Channel"} | Select-Object -ExpandProperty Value
            if ($srcChan -ne $dstChan) {
                Write-Error "Sorting mismatch between source and destination configuration."
                return
            }

            foreach ($srcProp in $src.Properties) {
                $dstProp = $dst.Properties | Where-Object Key -eq $srcProp.Key
                if ($null -eq $dstProp) {
                    Write-Verbose "Key '$($srcProp.Key)' not found on $($dst.Path)"
                    Write-Verbose "Available keys`r`n$($dst.Properties | Select-Object Key, Value | Format-Table)"
                    continue
                }
                if (!$srcProp.IsSettable -or $srcProp.ValueType -eq 'PathList' -or $srcProp.ValueType -eq 'Path') { continue }
                if ($srcProp.Value -ne $dstProp.Value) {
                    Write-Verbose "Changing $($dstProp.DisplayName) to $($srcProp.Value) on $($dst.Path)"
                    $dstProp.Value = $srcProp.Value
                    $dirty = $true
                }
            }
            if ($dirty) {
                if ($dst.ItemCategory -eq "ChildItem") {
                    $result = $lastParent | Set-ConfigurationItem
                } else {
                    $result = $dst | Set-ConfigurationItem
                }

                if (!$result.ValidatedOk) {
                    foreach ($errorResult in $result.ErrorResults) {
                        Write-Error $errorResult.ErrorText
                    }
                }
            }

            if ($src.Children.Count -eq $dst.Children.Count -and $src.Children.Count -gt 0) {
                foreach ($child in $src.Children) {
                    $srcStack.Push($child)
                }
                foreach ($child in $dst.Children) {
                    $dstStack.Push($child)
                }
                if ($dst.ItemCategory -eq "Item") {
                    $lastParent = $dst
                }
            } elseif ($src.Children.Count -ne 0) {
                Write-Warning "Number of child items is not equal on $($src.DisplayName)"
            }
        }
    }
}
function Copy-ViewGroupFromJson {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [pscustomobject]
        $Source,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $NewName,

        [Parameter()]
        [ValidateNotNull()]
        [VideoOS.Platform.ConfigurationItems.ViewGroup]
        $ParentViewGroup
    )

    process {
        if ($MyInvocation.BoundParameters.ContainsKey('NewName')) {
            ($source.Properties | Where-Object Key -eq 'Name').Value = $NewName
        }

        ##
        ## Clean duplicate views in export caused by config api bug
        ##

        $groups = [system.collections.generic.queue[pscustomobject]]::new()
        $groups.Enqueue($source)
        $views = [system.collections.generic.list[pscustomobject]]::new()
        while ($groups.Count -gt 0) {
            $group = $groups.Dequeue()
            $views.Clear()
            foreach ($v in ($group.Children | Where-Object ItemType -eq 'ViewFolder').Children) {
                # Can't believe I wrote this monstrosity of a line.
                if ($v.Path -notin (($group.Children | Where-Object ItemType -eq 'ViewGroupFolder').Children.Children | Where-Object ItemType -eq 'ViewFolder').Children.Path) {
                    $views.Add($v)
                } else {
                    Write-Verbose "Skipping duplicate view"
                }
            }
            if ($null -ne ($group.Children | Where-Object ItemType -eq 'ViewFolder').Children) {
                ($group.Children | Where-Object ItemType -eq 'ViewFolder').Children = $views.ToArray()
            }
            foreach ($childGroup in ($group.Children | Where-Object ItemType -eq 'ViewGroupFolder').Children) {
                $groups.Enqueue($childGroup)
            }
        }


        $rootFolder = Get-ConfigurationItem -Path /ViewGroupFolder
        if ($null -ne $ParentViewGroup) {
            $rootFolder = $ParentViewGroup.ViewGroupFolder | Get-ConfigurationItem
        }
        $newViewGroup = $null
        $stack = [System.Collections.Generic.Stack[pscustomobject]]::new()
        $stack.Push(([pscustomobject]@{ Folder = $rootFolder; Group = $source }))
        while ($stack.Count -gt 0) {
            $entry = $stack.Pop()
            $parentFolder = $entry.Folder
            $srcGroup = $entry.Group

            ##
            ## Create matching ViewGroup
            ##
            $invokeInfo = $parentFolder | Invoke-Method -MethodId 'AddViewGroup'
            foreach ($key in ($srcGroup.Properties | Where-Object IsSettable).Key) {
                $value = ($srcGroup.Properties | Where-Object Key -eq $key).Value
                ($invokeInfo.Properties | Where-Object Key -eq $key).Value = $value
            }
            $invokeResult = $invokeInfo | Invoke-Method -MethodId 'AddViewGroup'
            $props = ConvertPropertiesToHashtable -Properties $invokeResult.Properties
            if ($props.State.Value -ne 'Success') {
                Write-Error $props.ErrorText
            }
            $newViewFolder = Get-ConfigurationItem -Path "$($props.Path.Value)/ViewFolder"
            $newViewGroupFolder = Get-ConfigurationItem -Path "$($props.Path.Value)/ViewGroupFolder"
            if ($null -eq $newViewGroup) {
                $serverId = (Get-VmsManagementServer).ServerId
                $newViewGroup = [VideoOS.Platform.ConfigurationItems.ViewGroup]::new($serverId, $props.Path.Value)
            }

            ##
            ## Create all child views of the current view group
            ##
            foreach ($srcView in ($srcGroup.Children | Where-Object ItemType -eq ViewFolder).Children) {
                # Create new view based on srcView layout
                $invokeInfo = $newViewFolder | Invoke-Method -MethodId 'AddView'
                foreach ($key in ($invokeInfo.Properties | Where-Object IsSettable).Key) {
                    $value = ($srcView.Properties | Where-Object Key -eq $key).Value
                    ($invokeInfo.Properties | Where-Object Key -eq $key).Value = $value
                }
                $newView = $invokeInfo | Invoke-Method -MethodId 'AddView'

                # Rename view and update any other settable values
                foreach ($key in ($newView.Properties | Where-Object IsSettable).Key) {
                    $value = ($srcView.Properties | Where-Object Key -eq $key).Value
                    ($newView.Properties | Where-Object Key -eq $key).Value = $value
                }

                # Update all viewitems of new view to match srcView
                for ($i = 0; $i -lt $newView.Children.Count; $i++) {
                    foreach ($key in ($newView.Children[$i].Properties | Where-Object IsSettable).Key) {
                        $value = ($srcView.Children[$i].Properties | Where-Object Key -eq $key).Value
                        ($newView.Children[$i].Properties | Where-Object Key -eq $key).Value = $value
                    }
                }

                # Save changes to new view
                $invokeResult = $newView | Invoke-Method -MethodId 'AddView'
                $props = ConvertPropertiesToHashtable -Properties $invokeResult.Properties
                if ($props.State.Value -ne 'Success') {
                    Write-Error $props.ErrorText
                }
            }

            ##
            ## Get the new child ViewGroupFolder, and add all child view groups from the JSON object to the stack
            ##
            foreach ($childViewGroup in ($srcGroup.Children | Where-Object ItemType -eq ViewGroupFolder).Children) {
                $stack.Push(([pscustomobject]@{ Folder = $newViewGroupFolder; Group = $childViewGroup }))
            }
        }

        if ($null -ne $newViewGroup) {
            Write-Output $newViewGroup
        }
    }
}

function ConvertPropertiesToHashtable {
    param([VideoOS.ConfigurationApi.ClientService.Property[]]$Properties)

    $props = @{}
    foreach ($prop in $Properties) {
        $props[$prop.Key] = $prop
    }
    Write-Output $props
}
class CidrInfo {
    [string] $Cidr
    [IPAddress] $Address
    [int] $Mask

    [IPAddress] $Start
    [IPAddress] $End
    [IPAddress] $SubnetMask
    [IPAddress] $HostMask

    [int] $TotalAddressCount
    [int] $HostAddressCount

    CidrInfo([string] $Cidr) {
        [System.Net.IPAddress]$this.Address, [int]$this.Mask = $Cidr -split '/'
        if ($this.Address.AddressFamily -notin @([System.Net.Sockets.AddressFamily]::InterNetwork, [System.Net.Sockets.AddressFamily]::InterNetworkV6)) {
            throw "CidrInfo is not compatible with AddressFamily $($this.Address.AddressFamily). Expected InterNetwork or InterNetworkV6."
        }
        $min, $max = if ($this.Address.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) { 0, 32 } else { 0, 128 }
        if ($this.Mask -lt $min -or $this.Mask -gt $max) {
            throw "CIDR mask value out of range. Expected a value between $min and $max for AddressFamily $($this.Address.AddressFamily)"
        }
        $hostMaskLength = $max - $this.Mask
        $this.Cidr = $Cidr
        $this.TotalAddressCount = [math]::pow(2, $hostMaskLength)
        # RFC 3021 support is assumed. When the range supports only two hosts, RFC 3021 defines it usable for point-to-point communications but not all systems support this.
        $this.HostAddressCount = if ($hostMaskLength -eq 0) { 1 } elseif ($hostMaskLength -eq 1) { 2 } else { $this.TotalAddressCount - 2 }

        $addressBytes = $this.Address.GetAddressBytes()
        $netMaskBytes = [byte[]]::new($addressBytes.Count)
        $hostMaskBytes = [byte[]]::new($addressBytes.Count)
        $bitCounter = 0
        for ($octet = 0; $octet -lt $addressBytes.Count; $octet++) {
            for ($bit = 0; $bit -lt 8; $bit++) {
                $bitCounter += 1
                $bitValue = 0
                if ($bitCounter -le $this.Mask) {
                    $bitValue = 1
                }
                $netMaskBytes[$octet] = $netMaskBytes[$octet] -bor ( $bitValue -shl ( 7 - $bit ) )
                $hostMaskBytes[$octet] = $netMaskBytes[$octet] -bxor 255
            }
        }
        $this.SubnetMask = [ipaddress]::new($netMaskBytes)
        $this.HostMask = [IPAddress]::new($hostMaskBytes)

        $startBytes = [byte[]]::new($addressBytes.Count)
        $endBytes = [byte[]]::new($addressBytes.Count)
        for ($octet = 0; $octet -lt $addressBytes.Count; $octet++) {
            $startBytes[$octet] = $addressBytes[$octet] -band $netMaskBytes[$octet]
            $endBytes[$octet] = $addressBytes[$octet] -bor $hostMaskBytes[$octet]
        }
        $this.Start = [IPAddress]::new($startBytes)
        $this.End = [IPAddress]::new($endBytes)
    }
}

function Expand-IPRange {
    <#
    .SYNOPSIS
    Expands a start and end IP address or a CIDR notation into an array of IP addresses within the given range.
 
    .DESCRIPTION
    Accepts start and end IP addresses in the form of IPv4 or IPv6 addresses, and returns each IP
    address falling within the range including the Start and End values.
 
    The Start and End IP addresses must be in the same address family (IPv4 or IPv6) and if the
    addresses are IPv6, they must have the same scope ID.
 
    .PARAMETER Start
    Specifies the first IP address in the range to be expanded.
 
    .PARAMETER End
    Specifies the last IP address in the range to be expanded. Must be greater than or equal to Start.
 
    .PARAMETER Cidr
    Specifies an IP address range in CIDR notation. Example: 192.168.0.0/23 represents 192.168.0.0-192.168.1.255.
 
    .PARAMETER AsString
    Specifies that each IP address in the range should be returned as a string instead of an [IPAddress] object.
 
    .EXAMPLE
    PS C:\> Expand-IPRange -Start 192.168.1.1 -End 192.168.2.255
    Returns 511 IPv4 IPAddress objects.
 
    .EXAMPLE
    PS C:\> Expand-IPRange -Start fe80::5566:e22e:3f34:5a0f -End fe80::5566:e22e:3f34:5a16
    Returns 8 IPv6 IPAddress objects.
 
    .EXAMPLE
    PS C:\> Expand-IPRange -Start 10.1.1.100 -End 10.1.10.50 -AsString
    Returns 2255 IPv4 addresses as strings.
 
    .EXAMPLE
    PS C:\> Expand-IPRange -Cidr 172.16.16.0/23
    Returns IPv4 IPAddress objects from 172.16.16.0 to 172.16.17.255.
    #>

    [CmdletBinding(DefaultParameterSetName = 'FromRange')]
    [OutputType([System.Net.IPAddress], [string])]
    param(
        [Parameter(Mandatory, ParameterSetName = 'FromRange')]
        [ValidateScript({
            if ($_.AddressFamily -in @([System.Net.Sockets.AddressFamily]::InterNetwork, [System.Net.Sockets.AddressFamily]::InterNetworkV6)) {
                return $true
            }
            throw "Start IPAddress is from AddressFamily '$($_.AddressFamily)'. Expected InterNetwork or InterNetworkV6."
        })]
        [System.Net.IPAddress]
        $Start,

        [Parameter(Mandatory, ParameterSetName = 'FromRange')]
        [ValidateScript({
            if ($_.AddressFamily -in @([System.Net.Sockets.AddressFamily]::InterNetwork, [System.Net.Sockets.AddressFamily]::InterNetworkV6)) {
                return $true
            }
            throw "Start IPAddress is from AddressFamily '$($_.AddressFamily)'. Expected InterNetwork or InterNetworkV6."
        })]
        [System.Net.IPAddress]
        $End,

        [Parameter(Mandatory, ParameterSetName = 'FromCidr')]
        [ValidateNotNullOrEmpty()]
        [string]
        $Cidr,

        [Parameter()]
        [switch]
        $AsString
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'FromCidr') {
            $cidrInfo = [CidrInfo]$Cidr
            $Start = $cidrInfo.Start
            $End = $cidrInfo.End
        }

        if (-not $Start.AddressFamily.Equals($End.AddressFamily)) {
            throw 'Expand-IPRange received Start and End addresses from different IP address families (IPv4 and IPv6). Both addresses must be of the same IP address family.'
        }

        if ($Start.ScopeId -ne $End.ScopeId) {
            throw 'Expand-IPRange received IPv6 Start and End addresses with different ScopeID values. The ScopeID values must be identical.'
        }

        # Assert that the End IP is greater than or equal to the Start IP.
        $startBytes = $Start.GetAddressBytes()
        $endBytes = $End.GetAddressBytes()
        for ($i = 0; $i -lt $startBytes.Length; $i++) {
            if ($endBytes[$i] -lt $startBytes[$i]) {
                throw 'Expand-IPRange must receive an End IPAddress which is greater than or equal to the Start IPAddress'
            }
            if ($endBytes[$i] -gt $startBytes[$i]) {
                # We can break early if a higher-order byte from the End address is greater than the matching byte of the Start address
                break
            }
        }

        $current = $Start
        while ($true) {
            if ($AsString) {
                Write-Output $current.ToString()
            }
            else {
                Write-Output $current
            }

            if ($current.Equals($End)) {
                break
            }

            $bytes = $current.GetAddressBytes()
            for ($i = $bytes.Length - 1; $i -ge 0; $i--) {
                if ($bytes[$i] -lt 255) {
                    $bytes[$i] += 1
                    break
                }
                $bytes[$i] = 0
            }
            if ($null -ne $current.ScopeId) {
                $current = [System.Net.IPAddress]::new($bytes, $current.ScopeId)
            }
            else {
                $current = [System.Net.IPAddress]::new($bytes)
            }
        }
    }
}
function FillChildren {
    [CmdletBinding()]
    [OutputType([VideoOS.ConfigurationApi.ClientService.ConfigurationItem])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]
        $ConfigurationItem,

        [Parameter()]
        [int]
        $Depth = 1
    )

    process {
        $stack = New-Object System.Collections.Generic.Stack[VideoOS.ConfigurationApi.ClientService.ConfigurationItem]
        $stack.Push($ConfigurationItem)
        while ($stack.Count -gt 0) {
            $Depth = $Depth - 1
            $item = $stack.Pop()
            $item.Children = $item | Get-ConfigurationItem -ChildItems
            $item.ChildrenFilled = $true
            if ($Depth -gt 0) {
                $item.Children | Foreach-Object {
                    $stack.Push($_)
                }
            }
        }
        Write-Output $ConfigurationItem
    }
}
function Find-XProtectDeviceDialog {
    [CmdletBinding()]
    param ()

    process {
        Add-Type -AssemblyName PresentationFramework
        $xaml = [xml]@"
        <Window
                xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
                xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
                xmlns:local="clr-namespace:Search_XProtect"
                Title="Search XProtect" Height="500" Width="800"
                FocusManager.FocusedElement="{Binding ElementName=cboItemType}">
            <Grid>
                <GroupBox Name="gboAdvanced" Header="Advanced Parameters" HorizontalAlignment="Left" Height="94" Margin="506,53,0,0" VerticalAlignment="Top" Width="243"/>
                <Label Name="lblItemType" Content="Item Type" HorizontalAlignment="Left" Margin="57,22,0,0" VerticalAlignment="Top"/>
                <ComboBox Name="cboItemType" HorizontalAlignment="Left" Margin="124,25,0,0" VerticalAlignment="Top" Width="120" TabIndex="0">
                    <ComboBoxItem Content="Camera" HorizontalAlignment="Left" Width="118"/>
                    <ComboBoxItem Content="Hardware" HorizontalAlignment="Left" Width="118"/>
                    <ComboBoxItem Content="InputEvent" HorizontalAlignment="Left" Width="118"/>
                    <ComboBoxItem Content="Metadata" HorizontalAlignment="Left" Width="118"/>
                    <ComboBoxItem Content="Microphone" HorizontalAlignment="Left" Width="118"/>
                    <ComboBoxItem Content="Output" HorizontalAlignment="Left" Width="118"/>
                    <ComboBoxItem Content="Speaker" HorizontalAlignment="Left" Width="118"/>
                </ComboBox>
                <Label Name="lblName" Content="Name" HorizontalAlignment="Left" Margin="77,53,0,0" VerticalAlignment="Top" IsEnabled="False"/>
                <Label Name="lblPropertyName" Content="Property Name" HorizontalAlignment="Left" Margin="519,80,0,0" VerticalAlignment="Top" IsEnabled="False"/>
                <ComboBox Name="cboPropertyName" HorizontalAlignment="Left" Margin="614,84,0,0" VerticalAlignment="Top" Width="120" IsEnabled="False" TabIndex="5"/>
                <TextBox Name="txtName" HorizontalAlignment="Left" Height="23" Margin="124,56,0,0" TextWrapping="Wrap" Text="" VerticalAlignment="Top" Width="187" IsEnabled="False" TabIndex="1"/>
                <Button Name="btnSearch" Content="Search" HorizontalAlignment="Left" Margin="306,154,0,0" VerticalAlignment="Top" Width="75" TabIndex="7" IsEnabled="False"/>
                <DataGrid Name="dgrResults" HorizontalAlignment="Left" Height="207" Margin="36,202,0,0" VerticalAlignment="Top" Width="719" IsReadOnly="True"/>
                <Label Name="lblAddress" Content="IP Address" HorizontalAlignment="Left" Margin="53,84,0,0" VerticalAlignment="Top" IsEnabled="False"/>
                <TextBox Name="txtAddress" HorizontalAlignment="Left" Height="23" Margin="124,87,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="120" IsEnabled="False" TabIndex="2"/>
                <Label Name="lblEnabledFilter" Content="Enabled/Disabled" HorizontalAlignment="Left" Margin="506,22,0,0" VerticalAlignment="Top" IsEnabled="False"/>
                <ComboBox Name="cboEnabledFilter" HorizontalAlignment="Left" Margin="614,26,0,0" VerticalAlignment="Top" Width="120" IsEnabled="False" TabIndex="4">
                    <ComboBoxItem Content="Enabled" HorizontalAlignment="Left" Width="118"/>
                    <ComboBoxItem Content="Disabled" HorizontalAlignment="Left" Width="118"/>
                    <ComboBoxItem Name="cbiEnabledAll" Content="All" HorizontalAlignment="Left" Width="118" IsSelected="True"/>
                </ComboBox>
                <Label Name="lblMACAddress" Content="MAC Address" HorizontalAlignment="Left" Margin="37,115,0,0" VerticalAlignment="Top" IsEnabled="False"/>
                <TextBox Name="txtMACAddress" HorizontalAlignment="Left" Height="23" Margin="124,118,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="120" IsEnabled="False" TabIndex="3"/>
                <Label Name="lblPropertyValue" Content="Property Value" HorizontalAlignment="Left" Margin="522,108,0,0" VerticalAlignment="Top" IsEnabled="False"/>
                <TextBox Name="txtPropertyValue" HorizontalAlignment="Left" Height="23" Margin="614,111,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="120" IsEnabled="False" TabIndex="6"/>
                <Button Name="btnExportCSV" Content="Export CSV" HorizontalAlignment="Left" Margin="680,429,0,0" VerticalAlignment="Top" Width="75" TabIndex="9" IsEnabled="False"/>
                <Label Name="lblNoResults" Content="No results found!" HorizontalAlignment="Left" Margin="345,175,0,0" VerticalAlignment="Top" Foreground="Red" Visibility="Hidden"/>
                <Button Name="btnResetForm" Content="Reset Form" HorizontalAlignment="Left" Margin="414,154,0,0" VerticalAlignment="Top" Width="75" TabIndex="8"/>
                <Label Name="lblTotalResults" Content="Total Results:" HorizontalAlignment="Left" Margin="32,423,0,0" VerticalAlignment="Top" FontWeight="Bold"/>
                <TextBox Name="txtTotalResults" HorizontalAlignment="Left" Height="23" Margin="120,427,0,0" VerticalAlignment="Top" Width="53" IsEnabled="False"/>
                <Label Name="lblPropertyNameBlank" Content="Property Name cannot be blank if Property&#xD;&#xA;Value has an entry." HorizontalAlignment="Left" Margin="507,152,0,0" VerticalAlignment="Top" Foreground="Red" Width="248" Height="45" Visibility="Hidden"/>
                <Label Name="lblPropertyValueBlank" Content="Property Value cannot be blank if Property&#xA;Name has a selection." HorizontalAlignment="Left" Margin="507,152,0,0" VerticalAlignment="Top" Foreground="Red" Width="248" Height="45" Visibility="Hidden"/>
            </Grid>
        </Window>
"@


        function Clear-Results {
            $var_dgrResults.Columns.Clear()
            $var_dgrResults.Items.Clear()
            $var_txtTotalResults.Clear()
            $var_lblNoResults.Visibility = "Hidden"
            $var_lblPropertyNameBlank.Visibility = "Hidden"
            $var_lblPropertyValueBlank.Visibility = "Hidden"
        }

        $reader = [system.xml.xmlnodereader]::new($xaml)
        $window = [windows.markup.xamlreader]::Load($reader)
        $searchResults = $null

        # Create variables based on form control names.
        # Variable will be named as 'var_<control name>'
        $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
            #"trying item $($_.Name)"
            try {
                Set-Variable -Name "var_$($_.Name)" -Value $window.FindName($_.Name) -ErrorAction Stop
            } catch {
                throw
            }
        }
        # Get-Variable var_*

        $iconBase64 = "AAABAAEAICAAAAEAIACoEAAAFgAAACgAAAAgAAAAQAAAAAEAIAAAAAAAABAAAMMOAADDDgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADamQCA2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2pkAgNqZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADamQCA2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2pkAgNqZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADamQCA2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2pkAgNqZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADamQCA2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2pkAgNqZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADamQCA2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAA2pkAgNqZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAADamQCA2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgNqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQCAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAIAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQD/2pkA/9qZAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkA/9qZAP/amQCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAP/amQD/2pkAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQD/2pkA/9qZAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANqZAIDamQCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//5////8P///+B////AP///gB///wAP//4AB//8AAP/+AAB//AAAP/gAAB/wAAAP4AAAB8AAAAOAAAABAAAAAAAAAACAAAABwAAAA+AAAAfwAAAP+AAAH/wAAD/+AAB//wAA//+AAf//wAP//+AH///wD///+B////w////+f/8="
        $iconBytes = [Convert]::FromBase64String($iconBase64)
        $window.Icon = $iconBytes

        $assembly = [System.Reflection.Assembly]::GetAssembly([VideoOS.Platform.ConfigurationItems.Hardware])

        $excludedItems = "Folder|Path|Icon|Enabled|DisplayName|RecordingFramerate|ItemCategory|Wrapper|Address|Channel"

        $var_cboItemType.Add_SelectionChanged( {
                param($sender, $e)
                $itemType = $e.AddedItems[0].Content

                $var_cboPropertyName.Items.Clear()
                $var_dgrResults.Columns.Clear()
                $var_dgrResults.Items.Clear()
                $var_txtTotalResults.Clear()
                $var_txtPropertyValue.Clear()
                $var_lblNoResults.Visibility = "Hidden"
                $var_lblPropertyNameBlank.Visibility = "Hidden"
                $var_lblPropertyValueBlank.Visibility = "Hidden"

                $properties = ($assembly.GetType("VideoOS.Platform.ConfigurationItems.$itemType").DeclaredProperties | Where-Object { $_.PropertyType.Name -eq 'String' }).Name + ([VideoOS.Platform.ConfigurationItems.IConfigurationChildItem].DeclaredProperties | Where-Object { $_.PropertyType.Name -eq 'String' }).Name | Where-Object { $_ -notmatch $excludedItems }
                foreach ($property in $properties) {
                    $newComboboxItem = [System.Windows.Controls.ComboBoxItem]::new()
                    $newComboboxItem.AddChild($property)
                    $var_cboPropertyName.Items.Add($newComboboxItem)
                }

                $sortDescription = [System.ComponentModel.SortDescription]::new("Content", "Ascending")
                $var_cboPropertyName.Items.SortDescriptions.Add($sortDescription)

                $var_cboEnabledFilter.IsEnabled = $true
                $var_lblEnabledFilter.IsEnabled = $true
                $var_cboPropertyName.IsEnabled = $true
                $var_lblPropertyName.IsEnabled = $true
                $var_txtPropertyValue.IsEnabled = $true
                $var_lblPropertyValue.IsEnabled = $true
                $var_txtName.IsEnabled = $true
                $var_lblName.IsEnabled = $true
                $var_btnSearch.IsEnabled = $true

                if ($itemType -eq "Hardware") {
                    $var_txtAddress.IsEnabled = $true
                    $var_lblAddress.IsEnabled = $true
                    $var_txtMACAddress.IsEnabled = $true
                    $var_lblMACAddress.IsEnabled = $true
                } else {
                    $var_txtAddress.IsEnabled = $false
                    $var_txtAddress.Clear()
                    $var_lblAddress.IsEnabled = $false
                    $var_txtMACAddress.IsEnabled = $false
                    $var_txtMACAddress.Clear()
                    $var_lblMACAddress.IsEnabled = $false
                }
            })

        $var_txtName.Add_TextChanged( {
                Clear-Results
            })

        $var_txtAddress.Add_TextChanged( {
                Clear-Results
            })

        $var_txtMACAddress.Add_TextChanged( {
                Clear-Results
            })

        $var_cboEnabledFilter.Add_SelectionChanged( {
                Clear-Results
            })

        $var_cboPropertyName.Add_SelectionChanged( {
                Clear-Results
            })

        $var_txtPropertyValue.Add_TextChanged( {
                Clear-Results
            })

        $var_btnSearch.Add_Click( {
                if (-not [string]::IsNullOrEmpty($var_cboPropertyName.Text) -and [string]::IsNullOrEmpty($var_txtPropertyValue.Text)) {
                    $var_lblPropertyValueBlank.Visibility = "Visible"
                    Return
                } elseif ([string]::IsNullOrEmpty($var_cboPropertyName.Text) -and -not [string]::IsNullOrEmpty($var_txtPropertyValue.Text)) {
                    $var_lblPropertyNameBlank.Visibility = "Visible"
                    Return
                }

                $script:searchResults = Find-XProtectDeviceSearch -ItemType $var_cboItemType.Text -Name $var_txtName.Text -Address $var_txtAddress.Text -MAC $var_txtMACAddress.Text -Enabled $var_cboEnabledFilter.Text -PropertyName $var_cboPropertyName.Text -PropertyValue $var_txtPropertyValue.Text
                if ($null -ne $script:searchResults) {
                    $var_btnExportCSV.IsEnabled = $true
                } else {
                    $var_btnExportCSV.IsEnabled = $false
                }
            })

        $var_btnExportCSV.Add_Click( {
                $saveDialog = New-Object Microsoft.Win32.SaveFileDialog
                $saveDialog.Title = "Save As CSV"
                $saveDialog.Filter = "Comma delimited (*.csv)|*.csv"

                $saveAs = $saveDialog.ShowDialog()

                if ($saveAs -eq $true) {
                    $script:searchResults | Export-Csv -Path $saveDialog.FileName -NoTypeInformation
                }
            })

        $var_btnResetForm.Add_Click( {
                $var_dgrResults.Columns.Clear()
                $var_dgrResults.Items.Clear()
                $var_cboItemType.SelectedItem = $null
                $var_cboEnabledFilter.IsEnabled = $false
                $var_lblEnabledFilter.IsEnabled = $false
                $var_cbiEnabledAll.IsSelected = $true
                $var_cboPropertyName.IsEnabled = $false
                $var_cboPropertyName.Items.Clear()
                $var_lblPropertyName.IsEnabled = $false
                $var_txtPropertyValue.IsEnabled = $false
                $var_txtPropertyValue.Clear()
                $var_lblPropertyValue.IsEnabled = $false
                $var_txtName.IsEnabled = $false
                $var_txtName.Clear()
                $var_lblName.IsEnabled = $false
                $var_btnSearch.IsEnabled = $false
                $var_btnExportCSV.IsEnabled = $false
                $var_txtAddress.IsEnabled = $false
                $var_txtAddress.Clear()
                $var_lblAddress.IsEnabled = $false
                $var_txtMACAddress.IsEnabled = $false
                $var_txtMACAddress.Clear()
                $var_lblMACAddress.IsEnabled = $false
                $var_txtTotalResults.Clear()
                $var_lblNoResults.Visibility = "Hidden"
                $var_lblPropertyNameBlank.Visibility = "Hidden"
                $var_lblPropertyValueBlank.Visibility = "Hidden"
            })

        $null = $window.ShowDialog()
    }
}

function Find-XProtectDeviceSearch {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]$ItemType,
        [Parameter(Mandatory = $false)]
        [string]$Name,
        [Parameter(Mandatory = $false)]
        [string]$Address,
        [Parameter(Mandatory = $false)]
        [string]$MAC,
        [Parameter(Mandatory = $false)]
        [string]$Enabled,
        [Parameter(Mandatory = $false)]
        [string]$PropertyName,
        [Parameter(Mandatory = $false)]
        [string]$PropertyValue
    )

    process {
        $var_dgrResults.Columns.Clear()
        $var_dgrResults.Items.Clear()
        $var_lblNoResults.Visibility = "Hidden"
        $var_lblPropertyNameBlank.Visibility = "Hidden"
        $var_lblPropertyValueBlank.Visibility = "Hidden"

        if ([string]::IsNullOrEmpty($PropertyName) -or [string]::IsNullOrEmpty($PropertyValue)) {
            $PropertyName = "Id"
            $PropertyValue = $null
        }

        if ($ItemType -eq "Hardware" -and $null -eq [string]::IsNullOrEmpty($MAC)) {
            $results = [array](Find-XProtectDevice -ItemType $ItemType -MacAddress $MAC -EnableFilter $Enabled -Properties @{Name = $Name; Address = $Address; $PropertyName = $PropertyValue })
        } elseif ($ItemType -eq "Hardware" -and $null -ne [string]::IsNullOrEmpty($MAC)) {
            $results = [array](Find-XProtectDevice -ItemType $ItemType -EnableFilter $Enabled -Properties @{Name = $Name; Address = $Address; $PropertyName = $PropertyValue })
        } else {
            $results = [array](Find-XProtectDevice -ItemType $ItemType -EnableFilter $Enabled -Properties @{Name = $Name; $PropertyName = $PropertyValue })
        }

        if ($null -ne $results) {
            #$columnNames = ($results | Get-Member | Where-Object {$_.MemberType -eq 'NoteProperty'}).Name
            $columnNames = $results[0].PsObject.Properties | ForEach-Object { $_.Name }
        } else {
            $var_lblNoResults.Visibility = "Visible"
        }

        foreach ($columnName in $columnNames) {
            $newColumn = [System.Windows.Controls.DataGridTextColumn]::new()
            $newColumn.Header = $columnName
            $newColumn.Binding = New-Object System.Windows.Data.Binding($columnName)
            $newColumn.Width = "SizeToCells"
            $var_dgrResults.Columns.Add($newColumn)
        }

        if ($ItemType -eq "Hardware") {
            foreach ($result in $results) {
                $var_dgrResults.AddChild([pscustomobject]@{Hardware = $result.Hardware; RecordingServer = $result.RecordingServer })
            }
        } else {
            foreach ($result in $results) {
                $var_dgrResults.AddChild([pscustomobject]@{$columnNames[0] = $result.((Get-Variable -Name columnNames).Value[0]); Hardware = $result.Hardware; RecordingServer = $result.RecordingServer })
            }
        }

        $var_txtTotalResults.Text = $results.count
    }
    end {
        return $results
    }
}
function Get-DevicesByRecorder {
    <#
    .SYNOPSIS
        Gets all enabled cameras in a hashtable indexed by recording server id.
    .DESCRIPTION
        This cmdlet quickly returns a hashtable where the keys are recording
        server ID's and the values are lists of "VideoOS.Platform.Item" objects.
 
        The cmdlet will complete much quicker than if we were to use
        Get-RecordingServer | Get-VmsCamera, because it does not rely on the
        configuration API at all. Instead, it has the same functionality as
        XProtect Smart Client where the command "sees" only the devices that are enabled
        and loaded by the Recording Server.
    .EXAMPLE
        Get-CamerasByRecorder
        Name Value
        ---- -----
        bb82b2cd-0bb9-4c88-9cb8-128... {Canon VB-M40 (192.168.101.64) - Camera 1}
        f9dc2bcd-faea-4138-bf5a-32c... {Axis P1375 (10.1.77.178) - Camera 1, Test Cam}
 
        This is what the output would look like on a small system.
    .OUTPUTS
        [hashtable]
    #>

    [CmdletBinding()]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Id')]
        [guid[]]
        $RecordingServerId,

        [Parameter()]
        [Alias('Kind')]
        [ValidateSet('Camera', 'Microphone', 'Speaker', 'Metadata', IgnoreCase = $false)]
        [string[]]
        $DeviceType = 'Camera'
    )

    process {
        $config = [videoos.platform.configuration]::Instance
        $serverKind = [VideoOS.Platform.Kind]::Server
        $selectedKinds = @(($DeviceType | ForEach-Object { [VideoOS.Platform.Kind]::$_ }))
        $systemHierarchy = [VideoOS.Platform.ItemHierarchy]::SystemDefined

        $stack = [Collections.Generic.Stack[VideoOS.Platform.Item]]::new()
        $rootItems = $config.GetItems($systemHierarchy)
        foreach ($mgmtSrv in $rootItems | Where-Object { $_.FQID.Kind -eq $serverKind }) {
            foreach ($recorder in $mgmtSrv.GetChildren()) {
                if ($recorder.FQID.Kind -eq $serverKind -and ($RecordingServerId.Count -eq 0 -or $recorder.FQID.ObjectId -in $RecordingServerId)) {
                    $stack.Push($recorder)
                }
            }
        }

        $result = @{}
        $lastServerId = $null
        while ($stack.Count -gt 0) {
            $item = $stack.Pop()
            if ($item.FQID.Kind -eq $serverKind) {
                $lastServerId = $item.FQID.ObjectId
                $result.$lastServerId = [Collections.Generic.List[VideoOS.Platform.Item]]::new()
            } elseif ($item.FQID.Kind -in $selectedKinds -and $item.FQID.FolderType -eq 'No') {
                $result.$lastServerId.Add($item)
                continue
            }

            if ($item.HasChildren -ne 'No' -and ($item.FQID.Kind -eq $serverKind -or $item.FQID.Kind -in $selectedKinds)) {
                foreach ($child in $item.GetChildren()) {
                    if ($child.FQID.Kind -in $selectedKinds) {
                        $stack.Push($child)
                    }
                }
            }
        }
        Write-Output $result
    }
}
function Get-HttpSslCertThumbprint {
    <#
    .SYNOPSIS
        Gets the certificate thumbprint from the sslcert binding information put by netsh http show sslcert ipport=$IPPort
    .DESCRIPTION
        Gets the certificate thumbprint from the sslcert binding information put by netsh http show sslcert ipport=$IPPort.
        Returns $null if no binding is present for the given ip:port value.
    .PARAMETER IPPort
        The ip:port string representing the binding to retrieve the thumbprint from.
    .EXAMPLE
        Get-MobileServerSslCertThumbprint 0.0.0.0:8082
        Gets the sslcert thumbprint for the binding found matching 0.0.0.0:8082 which is the default HTTPS IP and Port for
        XProtect Mobile Server. The value '0.0.0.0' represents 'all interfaces' and 8082 is the default https port.
    #>

    [CmdletBinding()]
    param (
        [parameter(Mandatory)]
        [string]
        $IPPort
    )
    process {
        $netshOutput = [string](netsh.exe http show sslcert ipport=$IPPort)

        if (!$netshOutput.Contains('Certificate Hash')) {
            Write-Error "No SSL certificate binding found for $ipPort"
            return
        }

        if ($netshOutput -match "Certificate Hash\s+:\s+(\w+)\s+") {
            $Matches[1]
        } else {
            Write-Error "Certificate Hash not found for $ipPort"
        }
    }
}
function Get-ProcessOutput
{
    [CmdletBinding()]
    param (
        [Parameter(Mandatory=$true)]
        [string]
        $FilePath,
        [Parameter()]
        [string[]]
        $ArgumentList
    )
    
    process {
        try {
            $process = New-Object System.Diagnostics.Process
            $process.StartInfo.UseShellExecute = $false
            $process.StartInfo.RedirectStandardOutput = $true
            $process.StartInfo.RedirectStandardError = $true
            $process.StartInfo.FileName = $FilePath
            $process.StartInfo.CreateNoWindow = $true

            if($ArgumentList) { $process.StartInfo.Arguments = $ArgumentList }
            Write-Verbose "Executing $($FilePath) with the following arguments: $([string]::Join(' ', $ArgumentList))"
            $null = $process.Start()
    
            [pscustomobject]@{
                StandardOutput = $process.StandardOutput.ReadToEnd()
                StandardError = $process.StandardError.ReadToEnd()
                ExitCode = $process.ExitCode
            }
        }
        finally {
            $process.Dispose()
        }
        
    }
}
function GetCodecValueFromStream {
    param([VideoOS.Platform.ConfigurationItems.StreamChildItem]$Stream)

    $res = $Stream.Properties.GetValue("Codec")
    if ($null -ne $res) {
        ($Stream.Properties.GetValueTypeInfoCollection("Codec") | Where-Object Value -eq $res).Name
        return
    }
}
function GetFpsValueFromStream {
    param([VideoOS.Platform.ConfigurationItems.StreamChildItem]$Stream)

    $res = $Stream.Properties.GetValue("FPS")
    if ($null -ne $res) {
        $val = ($Stream.Properties.GetValueTypeInfoCollection("FPS") | Where-Object Value -eq $res).Name
        if ($null -eq $val) {
            $res
        }
        else {
            $val
        }
        return
    }

    $res = $Stream.Properties.GetValue("Framerate")
    if ($null -ne $res) {
        $val = ($Stream.Properties.GetValueTypeInfoCollection("Framerate") | Where-Object Value -eq $res).Name
        if ($null -eq $val) {
            $res
        }
        else {
            $val
        }
        return
    }
}
function GetResolutionValueFromStream {
    param([VideoOS.Platform.ConfigurationItems.StreamChildItem]$Stream)

    $res = $Stream.Properties.GetValue("StreamProperty")
    if ($null -ne $res) {
        ($Stream.Properties.GetValueTypeInfoCollection("StreamProperty") | Where-Object Value -eq $res).Name
        return
    }

    $res = $Stream.Properties.GetValue("Resolution")
    if ($null -ne $res) {
        ($Stream.Properties.GetValueTypeInfoCollection("Resolution") | Where-Object Value -eq $res).Name
        return
    }
}
function New-CameraViewItemDefinition {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VmsCameraViewItemProperties]
        $Properties
    )

    process {
        $template = @"
<viewitem id="{0}" displayname="Camera ViewItem" shortcut="{1}" type="VideoOS.RemoteClient.Application.Data.ContentTypes.CameraContentType.CameraViewItem, VideoOS.RemoteClient.Application" smartClientId="{2}">
    <iteminfo cameraid="{3}" lastknowncameradisplayname="{4}" livestreamid="{5}" imagequality="{6}" framerate="{7}" maintainimageaspectratio="{8}" usedefaultdisplaysettings="{9}" showtitlebar="{10}" keepimagequalitywhenmaximized="{11}" updateonmotiononly="{12}" soundonmotion="{13}" soundonevent="{14}" smartsearchgridwidth="{15}" smartsearchgridheight="{16}" smartsearchgridmask="{17}" pointandclickmode="{18}" usingproperties="True" />
    <properties>
        <property name="cameraid" value="{3}" />
        <property name="livestreamid" value="{5}" />
        <property name="framerate" value="{7}" />
        <property name="imagequality" value="{6}" />
        <property name="lastknowncameradisplayname" value="{4}" />
    </properties>
</viewitem>
"@

        $soundOnMotion = if ($Properties.SoundOnMotion) { 1 } else { 0 }
        $soundOnEvent  = if ($Properties.SoundOnEvent)  { 1 } else { 0 }
        $values = @(
            $Properties.Id,
            $Properties.Shortcut,
            $Properties.SmartClientId,
            $Properties.CameraId,
            $Properties.CameraName,
            $Properties.LiveStreamId,
            $Properties.ImageQuality,
            $Properties.Framerate,
            $Properties.MaintainImageAspectRatio,
            $Properties.UseDefaultDisplaySettings,
            $Properties.ShowTitleBar,
            $Properties.KeepImageQualityWhenMaximized,
            $Properties.UpdateOnMotionOnly,
            $soundOnMotion,
            $soundOnEvent,
            $Properties.SmartSearchGridWidth,
            $Properties.SmartSearchGridHeight,
            $Properties.SmartSearchGridMask,
            $Properties.PointAndClickMode
        )
        Write-Output ($template -f $values)
    }
}
function New-VmsViewItemProperties {
    [CmdletBinding()]
    [OutputType([VmsCameraViewItemProperties])]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [string]
        $Name,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('CameraId')]
        [guid]
        $Id,

        [Parameter()]
        [guid]
        $SmartClientId
    )

    process {
        $properties = [VmsCameraViewItemProperties]::new()
        $properties.CameraName = $Name
        $properties.CameraId = $Id
        if ($MyInvocation.BoundParameters.ContainsKey('SmartClientId')) {
            $properties.SmartClientId = $SmartClientId
        }
        Write-Output $properties
    }
}

function New-VmsViewLayout {
    [CmdletBinding(DefaultParameterSetName = 'Simple')]
    [OutputType([string])]
    param (
        [Parameter(ParameterSetName = 'Simple')]
        [ValidateRange(0, 100)]
        [int]
        $ViewItemCount = 1,

        [Parameter(ParameterSetName = 'Custom')]
        [ValidateRange(1, 100)]
        [int]
        $Columns,

        [Parameter(ParameterSetName = 'Custom')]
        [ValidateRange(1, 100)]
        [int]
        $Rows
    )

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Simple' {
                $size = 1
                if ($ViewItemCount -gt 0) {
                    $sqrt = [math]::Sqrt($ViewItemCount)
                    $size = [math]::Floor($sqrt)
                    if ($sqrt % 1) {
                        $size++
                    }
                }
                $Columns = $Rows = $size
                $width = $height = [math]::Floor(1000 / $size)
            }

            'Custom' {
                $width = [math]::Floor(1000 / $Columns)
                $height = [math]::Floor(1000 / $Rows)
            }
        }

        $template = '<ViewItem><Position><X>{0}</X><Y>{1}</Y></Position><Size><Width>{2}</Width><Height>{3}</Height></Size></ViewItem>'
        $xmlBuilder = [text.stringbuilder]::new()
        $null = $xmlBuilder.Append("<ViewItems>")
        for ($posY = 0; $posY -lt $Rows; $posY++) {
            for ($posX = 0; $posX -lt $Columns; $posX++) {
                $x = $width  * $posX
                $y = $height * $posY
                $null = $xmlBuilder.Append(($template -f $x, $y, $width, $height))
            }
        }
        $null = $xmlBuilder.Append("</ViewItems>")
        Write-Output $xmlBuilder.ToString()
    }
}
function OwnerInfoPropertyCompleter {
    param (
        $commandName,
        $parameterName,
        $wordToComplete,
        $commandAst,
        $fakeBoundParameters
    )

    $ownerPath = 'BasicOwnerInformation[{0}]' -f (Get-VmsManagementServer).Id
    $ownerInfo = Get-ConfigurationItem -Path $ownerPath
    $invokeInfo = $ownerInfo | Invoke-Method -MethodId AddBasicOwnerInfo
    $tagTypeInfo = $invokeInfo.Properties | Where-Object Key -eq 'TagType'
    $tagTypeInfo.ValueTypeInfos.Value | ForEach-Object { $_ }
}
function Set-CertKeyPermission {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Specifies the certificate store path to locate the certificate specified in Thumbprint. Example: Cert:\LocalMachine\My
        [Parameter()]
        [string]
        $CertificateStore = 'Cert:\LocalMachine\My',

        # Specifies the thumbprint of the certificate to which private key access should be updated.
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [string]
        $Thumbprint,

        # Specifies the Windows username for the identity to which permissions should be granted.
        [Parameter(Mandatory)]
        [string]
        $UserName,

        # Specifies the level of access to grant to the private key.
        [Parameter()]
        [ValidateSet('Read', 'FullControl')]
        [string]
        $Permission = 'Read',

        # Specifies the access type for the Access Control List rule.
        [Parameter()]
        [ValidateSet('Allow', 'Deny')]
        [string]
        $PermissionType = 'Allow'
    )

    process {
        <#
            There is a LOT of error checking in this function as it seems that certificates are not
            always consistently storing their private keys in predictable places. I've found private
            keys for RSA certs in ProgramData\Microsoft\Crypto\Keys instead of
            ProgramData\Microsoft\Crypto\RSA\MachineKeys, I've seen the UniqueName property contain
            a value representing the file name of the certificate private key file somewhere in the
            ProgramData\Microsoft\Crypto folder, and I've seen the UniqueName property contain a
            full file path to the private key file. I've also found that some RSA certs require you
            to use the RSA extension method to retrieve the private key, even though it seems like
            you should expect to find it in the PrivateKey property when retrieving the certificate
            from Get-ChildItem Cert:\LocalMachine\My.
        #>


        $certificate = Get-ChildItem -Path $CertificateStore | Where-Object Thumbprint -eq $Thumbprint
        Write-Verbose "Processing certificate for $($certificate.Subject) with thumbprint $($certificate.Thumbprint)"
        if ($null -eq $certificate) {
            Write-Error "Certificate not found in certificate store '$CertificateStore' matching thumbprint '$Thumbprint'"
            return
        }
        if (-not $certificate.HasPrivateKey) {
            Write-Error "Certificate with friendly name '$($certificate.FriendlyName)' issued to subject '$($certificate.Subject)' does not have a private key attached."
            return
        }
        $privateKey = $null
        switch ($certificate.PublicKey.EncodedKeyValue.Oid.FriendlyName) {
            'RSA' {
                $privateKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($certificate)
            }

            'ECC' {
                $privateKey = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPrivateKey($certificate)
            }

            'DSA' {
                Write-Error "Use of DSA-based certificates is not recommended, and not supported by this command. See https://docs.microsoft.com/en-us/dotnet/api/system.security.cryptography.dsa?view=net-5.0"
                return
            }

            Default { Write-Error "`$certificate.PublicKey.EncodedKeyValue.Oid.FriendlyName was '$($certificate.PublicKey.EncodedKeyValue.Oid.FriendlyName)'. Expected RSA, DSA or ECC."; return }
        }
        if ($null -eq $privateKey) {
            Write-Error "Certificate with friendly name '$($certificate.FriendlyName)' issued to subject '$($certificate.Subject)' does not have a private key attached."
            return
        }
        if ([string]::IsNullOrWhiteSpace($privateKey.Key.UniqueName)) {
            Write-Error "Certificate with friendly name '$($certificate.FriendlyName)' issued to subject '$($certificate.Subject)' does not have a value for the private key's UniqueName property so we cannot find the file on the filesystem associated with the private key."
            return
        }

        if (Test-Path -LiteralPath $privateKey.Key.UniqueName) {
            $privateKeyFile = Get-Item -Path $privateKey.Key.UniqueName
        }
        else {
            $privateKeyFile = Get-ChildItem -Path (Join-Path -Path ([system.environment]::GetFolderPath([system.environment+specialfolder]::CommonApplicationData)) -ChildPath ([io.path]::combine('Microsoft', 'Crypto'))) -Filter $privateKey.Key.UniqueName -Recurse -ErrorAction Ignore
            if ($null -eq $privateKeyFile) {
                Write-Error "No private key file found matching UniqueName '$($privateKey.Key.UniqueName)'"
                return
            }
            if ($privateKeyFile.Count -gt 1) {
                Write-Error "Found more than one private key file matching UniqueName '$($privateKey.Key.UniqueName)'"
                return
            }
        }

        $privateKeyPath = $privateKeyFile.FullName
        if (-not (Test-Path -Path $privateKeyPath)) {
            Write-Error "Expected to find private key file at '$privateKeyPath' but the file does not exist. You may need to re-install the certificate in the certificate store"
            return
        }

        $acl = Get-Acl -Path $privateKeyPath
        $rule = [Security.AccessControl.FileSystemAccessRule]::new($UserName, $Permission, $PermissionType)
        $acl.AddAccessRule($rule)
        if ($PSCmdlet.ShouldProcess($privateKeyPath, "Add FileSystemAccessRule")) {
            $acl | Set-Acl -Path $privateKeyPath
        }
    }
}
function ValidateSiteInfoTagName {
    $ownerPath = 'BasicOwnerInformation[{0}]' -f (Get-VmsManagementServer).Id
    $ownerInfo = Get-ConfigurationItem -Path $ownerPath
    $invokeInfo = $ownerInfo | Invoke-Method -MethodId AddBasicOwnerInfo
    $tagTypeInfo = $invokeInfo.Properties | Where-Object Key -eq 'TagType'
    if ($_ -cin $tagTypeInfo.ValueTypeInfos.Value) {
        $true
    } else {
        throw "$_ is not a valid BasicOwnerInformation property key."
    }
}
function Add-VmsDeviceGroupMember {
    [CmdletBinding()]
    [Alias('Add-DeviceGroupMember')]
    param (
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateScript({
            if ($_.GetType().Name -notmatch '^(Camera|Microphone|Speaker|InputEvent|Output|Metadata)Group$') {
                throw "Unexpected object type: $($_.GetType().FullName)"
            }
            $true
        })]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $Group,

        [Parameter(Mandatory, Position = 0, ParameterSetName = 'ByObject')]
        [ValidateScript({
            if ($_.GetType().Name -notmatch '^(Camera|Microphone|Speaker|InputEvent|Output|Metadata)$') {
                throw "Unexpected object type: $($_.GetType().FullName)"
            }
            $true
        })]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem[]]
        $Device,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 1, ParameterSetName = 'ById')]
        [Alias('Id')]
        [guid[]]
        $DeviceId
    )

    process {
        $dirty = $false
        $groupItemType = ($Group | ConvertTo-ConfigItemPath).ItemType -replace 'Group', ''
        try {
            if ($Device) {
                $DeviceId = $Device.Id
            }
            foreach ($id in $DeviceId) {
                try {
                    $path = '{0}[{1}]' -f $groupItemType, $id
                    $null = $Group."$($groupItemType)Folder".AddDeviceGroupMember($path)
                    $dirty = $true
                } catch [VideoOS.Platform.ArgumentMIPException] {
                    Write-Error -Message "Failed to add device group member: $_.Exception.Message" -Exception $_.Exception
                }
            }
        }
        finally {
            if ($dirty) {
                $Group."$($groupItemType)GroupFolder".ClearChildrenCache()
                (Get-VmsManagementServer)."$($groupItemType)GroupFolder".ClearChildrenCache()
            }
        }
    }
}
function Add-VmsFailoverGroup {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.FailoverGroup])]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ValueFromPipeline)]
        [string]
        $Name,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateNotNull()]
        [string]
        $Description = ''
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.2 -ErrorAction Stop
    }

    process {
        $ms = Get-VmsManagementServer
        if ($PSCmdlet.ShouldProcess($ms.Name, "Create failover group named '$Name'")) {
            $task = $ms.FailoverGroupFolder.AddFailoverGroup($Name, $Description)
            if ($task.State -ne 'Success') {
                Write-Error "Add-VmsFailoverGroup encounted an error. $($task.ErrorText.Trim('.'))."
                return
            }
            $id = $task.Path.Substring(14, 36)
            Get-VmsFailoverGroup -Id $id
        }
    }
}
function Add-VmsHardware {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.Hardware])]
    param (
        [Parameter(ParameterSetName = 'FromHardwareScan', Mandatory, ValueFromPipeline)]
        [VmsHardwareScanResult[]]
        $HardwareScan,

        [Parameter(ParameterSetName = 'Manual', Mandatory, ValueFromPipeline)]
        [RecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.RecordingServer]
        $RecordingServer,

        [Parameter(ParameterSetName = 'Manual', Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('Address')]
        [uri]
        $HardwareAddress,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter(ParameterSetName = 'Manual')]
        [int]
        $DriverNumber,

        [Parameter(ParameterSetName = 'Manual', ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]
        $HardwareDriverPath,

        [Parameter(ParameterSetName = 'Manual', Mandatory)]
        [pscredential]
        $Credential,

        [Parameter()]
        [switch]
        $SkipConfig,

        # Specifies that the hardware should be added, even if it already exists on another recording server.
        [Parameter()]
        [switch]
        $Force
    )

    process {
        $recorders = @{}
        $tasks = New-Object System.Collections.Generic.List[VideoOS.Platform.ConfigurationItems.ServerTask]
        switch ($PSCmdlet.ParameterSetName) {
            'Manual' {
                if ([string]::IsNullOrWhiteSpace($HardwareDriverPath)) {
                    if ($MyInvocation.BoundParameters.ContainsKey('DriverNumber')) {
                        $hardwareDriver = $RecordingServer.HardwareDriverFolder.HardwareDrivers | Where-Object Number -eq $DriverNumber
                        if ($null -ne $hardwareDriver) {
                            Write-Verbose "Mapped DriverNumber $DriverNumber to $($hardwareDriver.Name)"
                            $HardwareDriverPath = $hardwareDriver.Path
                        } else {
                            Write-Error "Failed to find hardware driver matching driver number $DriverNumber on Recording Server '$($RecordingServer.Name)'"
                            return
                        }
                    } else {
                        Write-Error "Add-VmsHardware cannot continue without either the HardwareDriverPath or the user-friendly driver number found in the supported hardware list."
                        return
                    }
                }
                $serverTask = $RecordingServer.AddHardware($HardwareAddress, $HardwareDriverPath, $Credential.UserName, $Credential.Password)
                $tasks.Add($serverTask)
                $recorders[$RecordingServer.Path] = $RecordingServer
            }
            'FromHardwareScan' {
                if ($HardwareScan.HardwareScanValidated -contains $false) {
                    Write-Warning "One or more scanned hardware could not be validated. These entries will be skipped."
                }
                if ($HardwareScan.MacAddressExistsLocal -contains $true) {
                    Write-Warning "One or more scanned hardware already exist on the target recording server. These entries will be skipped."
                }
                if ($HardwareScan.MacAddressExistsGlobal -contains $true -and -not $Force) {
                    Write-Warning "One or more scanned hardware already exist on another recording server. These entries will be skipped since the Force switch was not used."
                }
                foreach ($scan in $HardwareScan | Where-Object { $_.HardwareScanValidated -and -not $_.MacAddressExistsLocal }) {
                    if ($scan.MacAddressExistsGlobal -and -not $Force) {
                        continue
                    }
                    Write-Verbose "Adding $($scan.HardwareAddress) to $($scan.RecordingServer.Name) using driver identified by $($scan.HardwareDriverPath)"
                    $serverTask = $scan.RecordingServer.AddHardware($scan.HardwareAddress, $scan.HardwareDriverPath, $scan.UserName, $scan.Password)
                    $tasks.Add($serverTask)
                }
            }
        }
        if ($tasks.Count -eq 0) {
            return
        }
        Write-Verbose "Awaiting $($tasks.Count) AddHardware requests"
        Write-Verbose "Tasks: $([string]::Join(', ', $tasks.Path))"
        Wait-VmsTask -Path $tasks.Path -Title "Adding hardware to recording server(s) on site $((Get-Site).Name)" -Cleanup | Foreach-Object {
            $vmsTask = [VmsTaskResult]$_
            if ($vmsTask.State -eq [VmsTaskState]::Success) {
                $hardwareConfigItemPath = $vmsTask | ConvertTo-ConfigItemPath
                $newHardware = Get-Hardware -HardwareId $hardwareConfigItemPath.Id
                if ($null -eq $recorders[$newHardware.ParentItemPath]) {
                    Get-VmsRecordingServer | Where-Object Path -eq $newHardware.ParentItemPath | Foreach-Object {
                        $recorders[$_.Path] = $_
                    }
                }

                if (-not $SkipConfig) {
                    Set-NewHardwareConfig -Hardware $newHardware -Name $Name
                }
                if ($null -ne $newHardware) {
                    $newHardware
                }
            } else {
                Write-Error "Add-VmsHardware failed with error code $($vmsTask.ErrorCode). $($vmsTask.ErrorText)"
            }
        }

        $recorders.Values | Foreach-Object {
            $_.HardwareFolder.ClearChildrenCache()
        }
    }
}

function Set-NewHardwareConfig {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [VideoOS.Platform.ConfigurationItems.Hardware]
        $Hardware,

        [Parameter()]
        [string]
        $Name
    )

    process {
        $systemInfo = [videoos.platform.configuration]::Instance.FindSystemInfo((Get-Site).FQID.ServerId, $true)
        $version = $systemInfo.Properties.ProductVersion -as [version]
        $itemTypes = @('Camera')
        if (-not [string]::IsNullOrWhiteSpace($Name)) {
            $itemTypes += 'Microphone', 'Speaker', 'Metadata', 'InputEvent', 'Output'
        }
        if ($version -ge '20.2') {
            $Hardware.FillChildren($itemTypes)
        }

        $Hardware.Enabled = $true
        if (-not [string]::IsNullOrWhiteSpace($Name)) {
            $Hardware.Name = $Name
        }
        $Hardware.Save()

        foreach ($itemType in $itemTypes) {
            foreach ($item in $Hardware."$($itemType)Folder"."$($itemType)s") {
                if (-not [string]::IsNullOrWhiteSpace($Name)) {
                    $newName = '{0} - {1} {2}' -f $Name, $itemType.Replace('Event', ''), ($item.Channel + 1)
                    $item.Name = $newName
                }
                if ($itemType -eq 'Camera' -and $item.Channel -eq 0) {
                    $item.Enabled = $true
                }
                $item.Save()
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Add-VmsHardware -ParameterName RecordingServer -ScriptBlock {
    $values = (Get-VmsRecordingServer).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Add-VmsRoleMember {
    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ByAccountName')]
    [Alias('Add-User')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0, ParameterSetName = 'ByAccountName')]
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0, ParameterSetName = 'BySid')]
        [Alias('RoleName')]
        [ValidateNotNull()]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role[]]
        $Role,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 1, ParameterSetName = 'ByAccountName')]
        [string[]]
        $AccountName,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 2, ParameterSetName = 'BySid')]
        [string[]]
        $Sid
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ByAccountName') {
            $Sid = $AccountName | ConvertTo-Sid
        }
        foreach ($r in $Role) {
            foreach ($s in $Sid) {
                try {
                    if ($PSCmdlet.ShouldProcess($Role.Name, "Add member with SID $s to role")) {
                        $null = $r.UserFolder.AddRoleMember($s)
                    }
                }
                catch {
                    Write-Error -ErrorRecord $_
                }
            }
        }
    }
}


Register-ArgumentCompleter -CommandName Add-VmsRoleMember -ParameterName Role -ScriptBlock {
    Complete-SimpleArgument -Arguments $args -ValueSet (Get-VmsRole).Name | Sort-Object
}
function Clear-VmsSiteInfo {
    [CmdletBinding(SupportsShouldProcess)]
    param (
    )

    begin {
        Assert-VmsVersion -MinimumVersion 20.2 -Comment 'Support reading and modifying Site Information was introduced in XProtect 2020 R2.' -ErrorAction Stop
    }

    process {
        $ownerInfoFolder = (Get-VmsManagementServer).BasicOwnerInformationFolder
        $ownerInfoFolder.ClearChildrenCache()
        $ownerInfo = $ownerInfoFolder.BasicOwnerInformations[0]
        foreach ($key in $ownerInfo.Properties.KeysFullName) {
            if ($key -match '^\[(?<id>[a-fA-F0-9\-]{36})\]/(?<tagtype>[\w\.]+)$') {
                if ($PSCmdlet.ShouldProcess((Get-Site).Name, "Remove $($Matches.tagtype) entry with value '$($ownerInfo.Properties.GetValue($key))' in site information")) {
                    $invokeResult = $ownerInfo.RemoveBasicOwnerInfo($Matches.id)
                    if ($invokeResult.State -ne 'Success') {
                        Write-Error "An error occurred while removing a site information property: $($invokeResult.ErrorText)"
                    }
                }
            } else {
                Write-Warning "Site information property key format unrecognized: $key"
            }
        }
    }
}
function Clear-VmsView {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.View])]
    param (
        [Parameter(Mandatory, ValueFromPipeline, Position = 1)]
        [VideoOS.Platform.ConfigurationItems.View[]]
        $View,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        foreach ($v in $View) {
            if ($PSCmdlet.ShouldProcess($v.DisplayName, "Reset to empty ViewItem layout")) {
                foreach ($viewItem in $v.ViewItemChildItems) {
                    $id = New-Guid
                    $viewItem.ViewItemDefinitionXml = '<viewitem id="{0}" displayname="Empty ViewItem" shortcut="" type="VideoOS.RemoteClient.Application.Data.Configuration.EmptyViewItem, VideoOS.RemoteClient.Application"><properties /></viewitem>' -f $id.ToString()
                }
                $v.Save()
            }
            if ($PassThru) {
                Write-Output $View
            }
        }
    }
}
function ConvertFrom-ConfigurationItem {
    [CmdletBinding()]
    param(
        # Specifies the Milestone Configuration API 'Path' value of the configuration item. For example, 'Hardware[a6756a0e-886a-4050-a5a5-81317743c32a]' where the guid is the ID of an existing Hardware item.
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [string]
        $Path,

        # Specifies the Milestone 'ItemType' value such as 'Camera', 'Hardware', or 'InputEvent'
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [string]
        $ItemType
    )

    begin {
        $assembly = [System.Reflection.Assembly]::GetAssembly([VideoOS.Platform.ConfigurationItems.Hardware])
        $serverId = (Get-Site -ErrorAction Stop).FQID.ServerId
    }

    process {
        if ($Path -eq '/') {
            [VideoOS.Platform.ConfigurationItems.ManagementServer]::new($serverId)
        } else {
            $instance = $assembly.CreateInstance("VideoOS.Platform.ConfigurationItems.$ItemType", $false, [System.Reflection.BindingFlags]::Default, $null, (@($serverId, $Path)), $null, $null)
            Write-Output $instance
        }
    }
}
function Copy-VmsView {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.View])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.View[]]
        $View,

        [Parameter(Mandatory)]
        [VideoOS.Platform.ConfigurationItems.ViewGroup]
        $DestinationViewGroup,

        [Parameter()]
        [switch]
        $Force,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        foreach ($v in $View) {
            $newName = $v.Name
            if ($DestinationViewGroup.ViewFolder.Views.Name -contains $newName) {
                if ($Force) {
                    $existingView = $DestinationViewGroup.ViewFolder.Views | Where-Object Name -eq $v.Name
                    $existingView | Remove-VmsView -Confirm:$false
                } else {
                    while ($newName -in $DestinationViewGroup.ViewFolder.Views.Name) {
                        $newName = '{0} - Copy' -f $newName
                    }
                }
            }
            $params = @{
                Name = $newName
                LayoutDefinitionXml = $v.LayoutViewItems
                ViewItemDefinitionXml = $v.ViewItemChildItems.ViewItemDefinitionXml
            }
            $newView = $DestinationViewGroup | New-VmsView @params
            Write-Output $newView
        }
    }
}
function Copy-VmsViewGroup {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.ViewGroup])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.ViewGroup[]]
        $ViewGroup,

        [Parameter()]
        [ValidateNotNull()]
        [VideoOS.Platform.ConfigurationItems.ViewGroup]
        $DestinationViewGroup,

        [Parameter()]
        [switch]
        $Force,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        foreach ($vg in $ViewGroup) {
            $source = $vg | Get-ConfigurationItem -Recurse | ConvertTo-Json -Depth 100 -Compress | ConvertFrom-Json
            $destFolder = (Get-VmsManagementServer).ViewGroupFolder
            if ($MyInvocation.BoundParameters.ContainsKey('DestinationViewGroup')) {
                $destFolder = $DestinationViewGroup.ViewGroupFolder
            }
            $destFolder.ClearChildrenCache()
            $nameProp = $source.Properties | Where-Object Key -eq 'Name'
            if ($nameProp.Value -in $destFolder.ViewGroups.DisplayName -and $Force) {
                $existingGroup = $destFolder.ViewGroups | Where-Object DisplayName -eq $nameProp.Value
                if ($existingGroup.Path -ne $source.Path) {
                    Remove-VmsViewGroup -ViewGroup $existingGroup -Recurse
                }
            }
            while ($nameProp.Value -in $destFolder.ViewGroups.DisplayName) {
                $nameProp.Value = '{0} - Copy' -f $nameProp.Value
            }
            $params = @{
                Source = $source
            }
            if ($MyInvocation.BoundParameters.ContainsKey('DestinationViewGroup')) {
                $params.ParentViewGroup = $DestinationViewGroup
            }
            $newViewGroup = Copy-ViewGroupFromJson @params
            if ($PassThru) {
                Write-Output $newViewGroup
            }
        }
    }
}
function Export-VmsHardware {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param (
        [Parameter(ValueFromPipeline)]
        [RecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.RecordingServer[]]
        $RecordingServer,

        [Parameter()]
        [ValidateSet('All', 'Enabled', 'Disabled')]
        [string]
        $EnableFilter = 'Enabled',

        [Parameter()]
        [string]
        $Path,

        [Parameter()]
        [switch]
        $Append,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        $firstRun = $true
        try {
            Get-Site -ErrorAction Stop | Select-Site

            $version = [version](Get-VmsManagementServer).Version
            $supportsFillFeature = $version -ge '20.2'
            if (-not $supportsFillFeature) {
                Write-Warning "You are running version $version. Some Configuration API features introduced in 2020 R2 are unavailable, resulting in slower processing."
            }

            $deviceGroupCache = @{}
            'Camera', 'Microphone', 'Speaker', 'Metadata', 'Input', 'Output' | Foreach-Object {
                $deviceType = $_
                Write-Verbose "Processing $deviceType device group hierarchy"
                Get-VmsDeviceGroup -Type $deviceType -Recurse | Foreach-Object {
                    $group = $_
                    if ($deviceType -eq 'Input') {
                        $deviceType = 'InputEvent'
                    }
                    if (($group."$($deviceType)Folder"."$($deviceType)s").Count -gt 0) {
                        $groupPath = Resolve-VmsDeviceGroupPath -DeviceGroup $group
                        foreach ($device in $group."$($deviceType)Folder"."$($deviceType)s") {
                            if (-not $deviceGroupCache.ContainsKey($device.Id)) {
                                $deviceGroupCache.($device.Id) = New-Object System.Collections.Generic.List[string]
                            }
                            $deviceGroupCache.($device.Id).Add($groupPath)
                        }
                    }
                }
            }
        } catch {
            throw
        }
    }

    process {
        if ($null -eq $RecordingServer) {
            Write-Verbose 'Getting a list of all recording servers'
            $RecordingServer = Get-RecordingServer
        }

        foreach ($recorder in $RecordingServer) {
            $root = $recorder | Get-ConfigurationItem
            if ($supportsFillFeature) {
                $svc = Get-IConfigurationService
                $itemTypes = 'Hardware', 'Camera', 'Microphone', 'Speaker', 'Metadata', 'InputEvent', 'Output', 'HardwareDriver', 'Storage'
                $filters = $itemTypes | Foreach-Object {
                    [VideoOS.ConfigurationApi.ClientService.ItemFilter]::new($_, @(), [VideoOS.ConfigurationApi.ClientService.EnableFilter]::$EnableFilter)
                }
                $root.Children = $svc.GetChildItemsHierarchy($recorder.Path, $itemTypes, $filters)
                $root.ChildrenFilled = $true
            } else {
                $root = $root | FillChildren -Depth 4
            }
            $storageCache = @{}
            ($root.Children | Where-Object ItemType -eq 'StorageFolder').Children | ForEach-Object { $storageCache[$_.Path] = $_ }
            $driverCache = @{}
            ($root.Children | Where-Object ItemType -eq 'HardwareDriverFolder').Children | ForEach-Object { $driverCache[$_.Path] = $_ | Get-ConfigurationItemProperty -Key Number }

            foreach ($hardware in ($root.Children | Where-Object ItemType -eq 'HardwareFolder').Children) {
                $passwordTask = $hardware | Invoke-Method -MethodId ReadPasswordHardware | Invoke-Method -MethodId ReadPasswordHardware
                $password = ($passwordTask.Properties | Where-Object Key -eq 'Password').Value
                if ($null -eq $password) {
                    Write-Warning "Failed to retrieve password for $($hardware.DisplayName)"
                }

                $row = [ordered]@{
                    Address                   = $hardware | Get-ConfigurationItemProperty -Key Address
                    UserName                  = $hardware | Get-ConfigurationItemProperty -Key UserName
                    Password                  = $password
                    DriverNumber              = $driverCache[($hardware | Get-ConfigurationItemProperty -Key HardwareDriverPath)]
                    DriverFamily              = [string]::Empty
                    StorageName               = '' # Probably will want to use the first enabled camera's storage path as import only supports one path for all enabled devices
                    HardwareName              = $hardware.DisplayName
                    Coordinates               = ''

                    CameraName                = ''
                    MicrophoneName            = ''
                    SpeakerName               = ''
                    MetadataName              = ''
                    InputName                 = ''
                    OutputName                = ''

                    EnabledCameraChannels     = ''
                    EnabledMicrophoneChannels = ''
                    EnabledSpeakerChannels    = ''
                    EnabledMetadataChannels   = ''
                    EnabledInputChannels      = ''
                    EnabledOutputChannels     = ''

                    CameraGroup               = $null
                    MicrophoneGroup           = $null
                    SpeakerGroup              = $null
                    MetadataGroup             = $null
                    InputGroup                = $null
                    OutputGroup               = $null

                    RecordingServer           = $root.DisplayName
                    UseDefaultCredentials     = $false
                    Description               = $hardware | Get-ConfigurationItemProperty -Key Description
                }

                foreach ($deviceType in 'Camera', 'Microphone', 'Speaker', 'Metadata', 'Input', 'Output') {
                    $modifiedItemTypeName = $deviceType
                    if ($deviceType -eq 'Input') {
                        $modifiedItemTypeName = 'InputEvent'
                    }
                    $devices = ($hardware.Children | Where-Object ItemType -eq "$($modifiedItemTypeName)Folder").Children | Sort-Object { [int]($_ | Get-ConfigurationItemProperty -Key Channel) }
                    if ($devices.Count -eq 0) {
                        continue
                    }
                    $row["$($deviceType)Name"] = ($devices.DisplayName | Foreach-Object { $_.Replace(';', ':') }) -join ';'
                    $enabledDevices = @()
                    foreach ($device in $devices) {
                        if ([string]::IsNullOrWhiteSpace($row.StorageName)) {
                            $row.StorageName = $storageCache.($device | Get-ConfigurationItemProperty -Key RecordingStorage).DisplayName
                        }
                        if ([string]::IsNullOrWhiteSpace($row.Coordinates)) {
                            $geocoordinate = $device | Get-ConfigurationItemProperty -Key GisPoint | ConvertFrom-GisPoint
                            $row.Coordinates = if ($geocoordinate.IsUnknown) { '' } else { $geocoordinate.ToString() }
                        }
                        $channel = $device | Get-ConfigurationItemProperty -Key Channel
                        if ($device.EnableProperty.Enabled) {
                            $enabledDevices += $channel
                        }
                        $row["$($deviceType)Group"] = $deviceGroupCache[($device.Properties | Where-Object Key -eq 'Id' | Select-Object -ExpandProperty Value)] -join ';'
                    }
                    $row["Enabled$($deviceType)Channels"] = $enabledDevices -join ';'


                }

                $appendToCsv = $Append -or -not $firstRun
                $obj = [pscustomobject]$row
                if (-not [string]::IsNullOrWhiteSpace($Path)) {
                    $obj | Export-Csv -Path $Path -Append:$appendToCsv -NoTypeInformation
                    $firstRun = $false
                }
                if ([string]::IsNullOrWhiteSpace($Path) -or $PassThru) {
                    Write-Output $obj
                }
                $firstRun = $false
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Export-VmsHardware -ParameterName RecordingServer -ScriptBlock {
    $values = (Get-VmsRecordingServer).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Export-VmsLicenseRequest {
    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param (
        [Parameter(Mandatory)]
        [string]
        $Path,

        [Parameter()]
        [switch]
        $Force,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsVersion -MinimumVersion 20.2 -ErrorAction Stop -Comment "Management of Milestone XProtect VMS licensing using MIP SDK was introduced in version 2020 R2 (v20.2). This function is not compatible with your current Management Server version."
    }

    process {
        try {
            $filePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
            if ((Test-Path $filePath) -and -not $Force) {
                Write-Error "File '$Path' already exists. To overwrite an existing file, specify the -Force switch."
                return
            }
            $ms = Get-VmsManagementServer
            $result = $ms.LicenseInformationFolder.LicenseInformations[0].RequestLicense()
            if ($result.State -ne 'Success') {
                Write-Error "Failed to create license request. $($result.ErrorText.Trim('.'))."
                return
            }

            $content = [Convert]::FromBase64String($result.GetProperty('License'))
            [io.file]::WriteAllBytes($filePath, $content)

            if ($PassThru) {
                Get-Item -Path $filePath
            }
        }
        catch {
            Write-Error $_
        }
    }
}
function Export-VmsViewGroup {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.ViewGroup]
        $ViewGroup,

        [Parameter(Mandatory)]
        [string]
        $Path,

        [Parameter()]
        [switch]
        $Force
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        [environment]::CurrentDirectory = Get-Location
        $Path = [io.path]::GetFullPath($Path)
        $fileInfo = [io.fileinfo]::new($Path)

        if (-not $fileInfo.Directory.Exists) {
            if ($Force) {
                $null = New-Item -Path $fileInfo.Directory.FullName -ItemType Directory -Force
            } else {
                throw [io.DirectoryNotfoundexception]::new("Directory does not exist: $($fileInfo.Directory.FullName). Create the directory manually, or use the -Force switch.")
            }
        }

        if ($fileInfo.Exists -and -not $Force) {
            throw [invalidoperationexception]::new("File already exists. Use -Force to overwrite the existing file.")
        }
        $item = $ViewGroup | Get-ConfigurationItem -Recurse
        $json = $item | ConvertTo-Json -Depth 100 -Compress
        [io.file]::WriteAllText($Path, $json)
    }
}
function Find-ConfigurationItem {
    [CmdletBinding()]
    param (
        # Specifies all, or part of the display name of the configuration item to search for. For example, if you want to find a camera named "North West Parking" and you specify the value 'Parking', you will get results for any camera where 'Parking' appears in the name somewhere. The search is not case sensitive.
        [Parameter()]
        [string]
        $Name,

        # Specifies the type(s) of items to include in the results. The default is to include only 'Camera' items.
        [Parameter()]
        [string[]]
        $ItemType = 'Camera',

        # Specifies whether all matching items should be included, or whether only enabled, or disabled items should be included in the results. The default is to include all items regardless of state.
        [Parameter()]
        [ValidateSet('All', 'Disabled', 'Enabled')]
        [string]
        $EnableFilter = 'All',

        # An optional hashtable of additional property keys and values to filter results. Properties must be string types, and the results will be included if the property key exists, and the value contains the provided string.
        [Parameter()]
        [hashtable]
        $Properties = @{}
    )

    process {
        $svc = Get-IConfigurationService -ErrorAction Stop
        $itemFilter = [VideoOS.ConfigurationApi.ClientService.ItemFilter]::new()
        $itemFilter.EnableFilter = [VideoOS.ConfigurationApi.ClientService.EnableFilter]::$EnableFilter

        $propertyFilters = New-Object System.Collections.Generic.List[VideoOS.ConfigurationApi.ClientService.PropertyFilter]
        if (-not [string]::IsNullOrWhiteSpace($Name) -and $Name -ne '*') {
            $Properties.Name = $Name
        }
        foreach ($key in $Properties.Keys) {
            $propertyFilters.Add([VideoOS.ConfigurationApi.ClientService.PropertyFilter]::new(
                    $key,
                    [VideoOS.ConfigurationApi.ClientService.Operator]::Contains,
                    $Properties.$key
                ))
        }
        $itemFilter.PropertyFilters = $propertyFilters

        foreach ($type in $ItemType) {
            $itemFilter.ItemType = $type
            $svc.QueryItems($itemFilter, [int]::MaxValue) | Foreach-Object {
                Write-Output $_
            }
        }
    }
}

$ItemTypeArgCompleter = {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    ([VideoOS.ConfigurationAPI.ItemTypes] | Get-Member -Static -MemberType Property).Name | Where-Object {
        $_ -like "$wordToComplete*"
    } | Foreach-Object {
        "'$_'"
    }
}
Register-ArgumentCompleter -CommandName Find-ConfigurationItem -ParameterName ItemType -ScriptBlock $ItemTypeArgCompleter
Register-ArgumentCompleter -CommandName ConvertFrom-ConfigurationItem -ParameterName ItemType -ScriptBlock $ItemTypeArgCompleter
function Find-XProtectDevice {
    [CmdletBinding()]
    param(
        # Specifies the ItemType such as Camera, Microphone, or InputEvent. Default is 'Camera'.
        [Parameter()]
        [ValidateSet('Hardware', 'Camera', 'Microphone', 'Speaker', 'InputEvent', 'Output', 'Metadata')]
        [string[]]
        $ItemType = 'Camera',

        # Specifies name, or part of the name of the device(s) to find.
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        # Specifies all or part of the IP or hostname of the hardware device to search for.
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $Address,

        # Specifies all or part of the MAC address of the hardware device to search for. Note: Searching by MAC is significantly slower than searching by IP.
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $MacAddress,

        # Specifies whether all devices should be returned, or only enabled or disabled devices. Default is to return all matching devices.
        [Parameter()]
        [ValidateSet('All', 'Disabled', 'Enabled')]
        [string]
        $EnableFilter = 'All',

        # Specifies an optional hash table of key/value pairs matching properties on the items you're searching for.
        [Parameter()]
        [hashtable]
        $Properties = @{},

        [Parameter(ParameterSetName = 'ShowDialog')]
        [switch]
        $ShowDialog
    )

    begin {
        $loginSettings = Get-LoginSettings
        if ([version]'20.2' -gt [version]$loginSettings.ServerProductInfo.ProductVersion) {
            throw "The QueryItems feature was added to Milestone XProtect VMS versions starting with version 2020 R2 (v20.2). The current site is running $($loginSettings.ServerProductInfo.ProductVersion). Please upgrade to 2020 R2 or later for access to this feature."
        }
    }

    process {
        if ($ShowDialog) {
            Find-XProtectDeviceDialog
            return
        }
        if ($MyInvocation.BoundParameters.ContainsKey('Address')) {
            $ItemType = 'Hardware'
            $Properties.Address = $Address
        }

        if ($MyInvocation.BoundParameters.ContainsKey('MacAddress')) {
            $ItemType = 'Hardware'
            $MacAddress = $MacAddress.Replace(':', '').Replace('-', '')
        }
        # When many results are returned, this hashtable helps avoid unnecessary configuration api queries by caching parent items and indexing by their Path property
        $pathToItemMap = @{}

        Find-ConfigurationItem -ItemType $ItemType -EnableFilter $EnableFilter -Name $Name -Properties $Properties | Foreach-Object {
            $item = $_
            if (![string]::IsNullOrWhiteSpace($MacAddress)) {
                $hwid = ($item.Properties | Where-Object Key -eq 'Id').Value
                $mac = ((Get-ConfigurationItem -Path "HardwareDriverSettings[$hwid]").Children[0].Properties | Where-Object Key -like '*/MacAddress/*' | Select-Object -ExpandProperty Value).Replace(':', '').Replace('-', '')
                if ($mac -notlike "*$MacAddress*") {
                    return
                }
            }
            $deviceInfo = [ordered]@{}
            while ($true) {
                $deviceInfo.($item.ItemType) = $item.DisplayName
                if ($item.ItemType -eq 'RecordingServer') {
                    break
                }
                $parentItemPath = $item.ParentPath -split '/' | Select-Object -First 1

                # Set $item to the cached copy of that parent item if available. If not, retrieve it using configuration api and cache it.
                if ($pathToItemMap.ContainsKey($parentItemPath)) {
                    $item = $pathToItemMap.$parentItemPath
                } else {
                    $item = Get-ConfigurationItem -Path $parentItemPath
                    $pathToItemMap.$parentItemPath = $item
                }
            }
            [pscustomobject]$deviceInfo
        }
    }
}
function Get-ManagementServerConfig {
    [CmdletBinding()]
    param()

    begin {
        $configXml = Join-Path ([system.environment]::GetFolderPath([System.Environment+SpecialFolder]::CommonApplicationData)) 'milestone\xprotect management server\serverconfig.xml'
        if (-not (Test-Path $configXml)) {
            throw [io.filenotfoundexception]::new('Management Server configuration file not found', $configXml)
        }
    }

    process {
        $xml = [xml](Get-Content -Path $configXml)
        
        $versionNode = $xml.SelectSingleNode('/server/version')
        $clientRegistrationIdNode = $xml.SelectSingleNode('/server/ClientRegistrationId')
        $webApiPortNode = $xml.SelectSingleNode('/server/WebApiConfig/Port')
        $authServerAddressNode = $xml.SelectSingleNode('/server/WebApiConfig/AuthorizationServerUri')


        $serviceProperties = 'Name', 'PathName', 'StartName', 'ProcessId', 'StartMode', 'State', 'Status'
        $serviceInfo = Get-CimInstance -ClassName 'Win32_Service' -Property $serviceProperties -Filter "name = 'Milestone XProtect Management Server'"

        $config = @{
            Version = if ($null -ne $versionNode) { [version]::Parse($versionNode.InnerText) } else { [version]::new(0, 0) }
            ClientRegistrationId = if ($null -ne $clientRegistrationIdNode) { [guid]$clientRegistrationIdNode.InnerText } else { [guid]::Empty }
            WebApiPort = if ($null -ne $webApiPortNode) { [int]$webApiPortNode.InnerText } else { 0 }
            AuthServerAddress = if ($null -ne $authServerAddressNode) { [uri]$authServerAddressNode.InnerText } else { $null }
            ServerCertHash = $null
            InstallationPath = $serviceInfo.PathName.Trim('"')
            ServiceInfo = $serviceInfo
        }

        $netshResult = Get-ProcessOutput -FilePath 'netsh.exe' -ArgumentList "http show sslcert ipport=0.0.0.0:$($config.WebApiPort)"
        if ($netshResult.StandardOutput -match 'Certificate Hash\s+:\s+(\w+)\s+') {
            $config.ServerCertHash = $Matches.1
        }

        Write-Output ([pscustomobject]$config)
    }
}

function Get-PlaybackInfo {
    [CmdletBinding(DefaultParameterSetName = 'FromPath')]
    param (
        # Accepts a Milestone Configuration Item path string like Camera[A64740CF-5511-4957-9356-2922A25FF752]
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'FromPath')]
        [ValidateScript( {
                if ($_ -notmatch '^(?<ItemType>\w+)\[(?<Id>[a-fA-F0-9\-]{36})\]$') {
                    throw "$_ does not a valid Milestone Configuration API Item path"
                }
                if ($Matches.ItemType -notin @('Camera', 'Microphone', 'Speaker', 'Metadata')) {
                    throw "$_ represents an item of type '$($Matches.ItemType)'. Only camera, microphone, speaker, or metadata item types are allowed."
                }
                return $true
            })]
        [string[]]
        $Path,

        # Accepts a Camera, Microphone, Speaker, or Metadata object
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'FromDevice')]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem[]]
        $Device,

        [Parameter()]
        [ValidateSet('MotionSequence', 'RecordingSequence', 'TimelineMotionDetected', 'TimelineRecording')]
        [string]
        $SequenceType = 'RecordingSequence',

        [Parameter()]
        [switch]
        $Parallel,

        [Parameter(ParameterSetName = 'DeprecatedParameterSet')]
        [VideoOS.Platform.ConfigurationItems.Camera]
        $Camera,

        [Parameter(ParameterSetName = 'DeprecatedParameterSet')]
        [guid]
        $CameraId,

        [Parameter(ParameterSetName = 'DeprecatedParameterSet')]
        [switch]
        $UseLocalTime
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'DeprecatedParameterSet') {
            Write-Warning 'The Camera, CameraId, and UseLocalTime parameters are deprecated. See "Get-Help Get-PlaybackInfo -Full" for more information.'
            if ($null -ne $Camera) {
                $Path = $Camera.Path
            }
            else{
                $Path = "Camera[$CameraId]"
            }
        }
        if ($PSCmdlet.ParameterSetName -eq 'FromDevice') {
            $Path = $Device.Path
        }
        if ($Path.Count -le 60 -and $Parallel) {
            Write-Warning "Ignoring the Parallel switch since there are only $($Path.Count) devices to query."
            $Parallel = $false
        }

        if ($Parallel) {
            $jobRunner = [LocalJobRunner]::new()
        }


        $script = {
            param([string]$Path, [string]$SequenceType)
            if ($Path -notmatch '^(?<ItemType>\w+)\[(?<Id>[a-fA-F0-9\-]{36})\]$') {
                Write-Error "Path '$Path' is not a valid Milestone Configuration API item path."
                return
            }
            try {
                $site = Get-Site
                $epoch = [datetime]::SpecifyKind([datetimeoffset]::FromUnixTimeSeconds(0).DateTime, [datetimekind]::utc)
                $item = [videoos.platform.Configuration]::Instance.GetItem($site.FQID.ServerId, $Matches.Id, [VideoOS.Platform.Kind]::($Matches.ItemType))
                if ($null -eq $item) {
                    Write-Error "Camera not available. It may be disabled, or it may not belong to a camera group."
                    return
                }
                $sds = [VideoOS.Platform.Data.SequenceDataSource]::new($item)
                $sequenceTypeGuid = [VideoOS.Platform.Data.DataType+SequenceTypeGuids]::$SequenceType
                $first = $sds.GetData($epoch, [timespan]::zero, 0, ([datetime]::utcnow - $epoch), 1, $sequenceTypeGuid) | Select-Object -First 1
                $last = $sds.GetData([datetime]::utcnow, ([datetime]::utcnow - $epoch), 1, [timespan]::zero, 0, $sequenceTypeGuid) | Select-Object -First 1
                if ($first.EventSequence -and $last.EventSequence) {
                    [PSCustomObject]@{
                        Begin = $first.EventSequence.StartDateTime
                        End   = $last.EventSequence.EndDateTime
                        Retention = $last.EventSequence.EndDateTime - $first.EventSequence.StartDateTime
                        Path = $Path
                    }
                }
                else {
                    Write-Warning "No sequences of type '$SequenceType' found for $(($Matches.ItemType).ToLower()) $($item.Name) ($($item.FQID.ObjectId))"
                }
            } finally {
                if ($sds) {
                    $sds.Close()
                }
            }
        }

        try {
            foreach ($p in $Path) {
                if ($Parallel) {
                    $null = $jobRunner.AddJob($script, @{Path = $p; SequenceType = $SequenceType})
                }
                else {
                    $script.Invoke($p, $SequenceType) | Foreach-Object {
                        if ($UseLocalTime) {
                            $_.Begin = $_.Begin.ToLocalTime()
                            $_.End = $_.End.ToLocalTime()
                        }
                        $_
                    }
                }
            }

            if ($Parallel) {
                while ($jobRunner.HasPendingJobs()) {
                    $jobRunner.ReceiveJobs() | Foreach-Object {
                        if ($_.Output) {
                            if ($UseLocalTime) {
                                $_.Output.Begin = $_.Output.Begin.ToLocalTime()
                                $_.Output.End = $_.Output.End.ToLocalTime()
                            }
                            Write-Output $_.Output
                        }
                        if ($_.Errors) {
                            $_.Errors | Foreach-Object {
                                Write-Error $_
                            }
                        }
                    }
                    Start-Sleep -Milliseconds 200
                }
            }
        }
        finally {
            if ($jobRunner) {
                $jobRunner.Dispose()
            }
        }
    }
}
function Get-RecorderConfig {
    [CmdletBinding()]
    param()

    begin {
        $configXml = Join-Path ([system.environment]::GetFolderPath([System.Environment+SpecialFolder]::CommonApplicationData)) 'milestone\xprotect recording server\recorderconfig.xml'
        if (-not (Test-Path $configXml)) {
            throw [io.filenotfoundexception]::new('Recording Server configuration file not found', $configXml)
        }
    }

    process {
        $xml = [xml](Get-Content -Path $configXml)
        
        $versionNode = $xml.SelectSingleNode('/recorderconfig/version')
        $recorderIdNode = $xml.SelectSingleNode('/recorderconfig/recorder/id')
        $clientRegistrationIdNode = $xml.SelectSingleNode('/recorderconfig/recorder/ClientRegistrationId')
        $webServerPortNode = $xml.SelectSingleNode('/recorderconfig/webserver/port')        
        $alertServerPortNode = $xml.SelectSingleNode('/recorderconfig/driverservices/alert/port')
        $serverAddressNode = $xml.SelectSingleNode('/recorderconfig/server/address')        
        $serverPortNode = $xml.SelectSingleNode('/recorderconfig/server/webapiport')        
        $localServerPortNode = $xml.SelectSingleNode('/recorderconfig/webapi/port')
        $webApiPortNode = $xml.SelectSingleNode('/server/WebApiConfig/Port')
        $authServerAddressNode = $xml.SelectSingleNode('/recorderconfig/server/authorizationserveraddress')
        $clientCertHash = $xml.SelectSingleNode('/recorderconfig/webserver/encryption').Attributes['certificateHash'].Value

        $serviceProperties = 'Name', 'PathName', 'StartName', 'ProcessId', 'StartMode', 'State', 'Status'
        $serviceInfo = Get-CimInstance -ClassName 'Win32_Service' -Property $serviceProperties -Filter "name = 'Milestone XProtect Recording Server'"

        $config = @{
            Version = if ($null -ne $versionNode) { [version]::Parse($versionNode.InnerText) } else { [version]::new(0, 0) }
            RecorderId = if ($null -ne $recorderIdNode) { [guid]$recorderIdNode.InnerText } else { [guid]::Empty }
            ClientRegistrationId = if ($null -ne $clientRegistrationIdNode) { [guid]$clientRegistrationIdNode.InnerText } else { [guid]::Empty }
            WebServerPort = if ($null -ne $webServerPortNode) { [int]$webServerPortNode.InnerText } else { 0 }
            AlertServerPort = if ($null -ne $alertServerPortNode) { [int]$alertServerPortNode.InnerText } else { 0 }
            ServerAddress = $serverAddressNode.InnerText
            ServerPort = if ($null -ne $serverPortNode) { [int]$serverPortNode.InnerText } else { 0 }
            LocalServerPort = if ($null -ne $localServerPortNode) { [int]$localServerPortNode.InnerText } else { 0 }
            AuthServerAddress = if ($null -ne $authServerAddressNode) { [uri]$authServerAddressNode.InnerText } else { $null }
            ServerCertHash = $null
            InstallationPath = $serviceInfo.PathName.Trim('"')
            DevicePackPath = Get-ItemPropertyValue -Path HKLM:\SOFTWARE\WOW6432Node\VideoOS\DeviceDrivers -Name InstallPath
            ServiceInfo = $serviceInfo
        }

        $netshResult = Get-ProcessOutput -FilePath 'netsh.exe' -ArgumentList "http show sslcert ipport=0.0.0.0:$($config.LocalServerPort)"
        if ($netshResult.StandardOutput -match 'Certificate Hash\s+:\s+(\w+)\s+') {
            $config.ServerCertHash = $Matches.1
        }

        Write-Output ([pscustomobject]$config)
    }
}
function Get-VmsCamera {
    [CmdletBinding(DefaultParameterSetName = 'BySearch')]
    [Alias('Get-Camera')]
    [OutputType([VideoOS.Platform.ConfigurationItems.Camera])]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'ById')]
        [guid[]]
        $Id,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'ByHardware')]
        [VideoOS.Platform.ConfigurationItems.Hardware[]]
        $Hardware,

        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'BySearch')]
        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'ByHardware')]
        [ValidateRange(0, [int]::MaxValue)]
        [int[]]
        $Channel,

        [Parameter(ParameterSetName = 'ByHardware')]
        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'BySearch')]
        [string]
        $Name,

        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'BySearch')]
        [string]
        $Description,

        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'BySearch')]
        [ValidateSet(
            [VideoOS.ConfigurationApi.ClientService.Operator]::Equals,
            [VideoOS.ConfigurationApi.ClientService.Operator]::NotEquals,
            [VideoOS.ConfigurationApi.ClientService.Operator]::Contains,
            [VideoOS.ConfigurationApi.ClientService.Operator]::BeginsWith
        )]
        [VideoOS.ConfigurationApi.ClientService.Operator]
        $Comparison = [VideoOS.ConfigurationApi.ClientService.Operator]::Contains,

        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'BySearch')]
        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'ByHardware')]
        [VideoOS.ConfigurationApi.ClientService.EnableFilter]
        $EnableFilter = [VideoOS.ConfigurationApi.ClientService.EnableFilter]::Enabled,

        [Parameter(ParameterSetName = 'BySearch')]
        [timespan]
        $Timeout = [timespan]::FromSeconds(15),

        [Parameter(ParameterSetName = 'BySearch')]
        [ValidateRange(0, [int]::MaxValue)]
        [int]
        $MaxResults = [int]::MaxValue
    )

    begin {
        Assert-VmsConnected -ErrorAction Stop
        $aliasWarningsDelivered = 0

        $enabledCameras = @{}
        $stack = [system.collections.generic.stack[VideoOS.Platform.Item]]::new()
        [videoos.platform.configuration]::Instance.GetItemsByKind([videoos.platform.kind]::Camera, [videoos.platform.itemhierarchy]::SystemDefined) | Foreach-Object {
            $stack.Push($_)
        }
        while ($stack.Count -gt 0) {
            $item = $stack.Pop()
            $item.GetChildren() | Foreach-Object {
                $stack.Push($_)
            }
            if ($item.FQID.Kind -eq [videoos.platform.kind]::Camera -and $item.FQID.FolderType -eq 'No') {
                $enabledCameras[$item.FQID.ObjectId] = $null
            }
        }
    }

    process {
        # TODO: Remove the alias and this block eventually.
        if ($PSCmdlet.MyInvocation.InvocationName -eq 'Get-Camera' -and $aliasWarningsDelivered -eq 0) {
            Write-Warning "The Get-Camera command is deprecated. For compatibility purposes it is temporarily aliased to Get-VmsCamera."
            if (-not $MyInvocation.BoundParameters.ContainsKey('EnableFilter')) {
                Write-Warning "The default behavior of Get-VmsCamera is to return only enabled devices, but while using the Get-Camera alias, the behavior matches the original Get-Camera command and returns all cameras, including disabled cameras."
                $EnableFilter = [VideoOS.ConfigurationApi.ClientService.EnableFilter]::All
            }
            $aliasWarningsDelivered++
        }

        switch ($PSCmdlet.ParameterSetName) {
            'ByHardware' {
                $filterChannels = $MyInvocation.BoundParameters.ContainsKey('Channel')
                foreach ($h in $Hardware) {
                    if ($EnableFilter -eq 'Enabled' -and $h.Disabled) {
                        continue
                    }
                    foreach ($camera in $h.CameraFolder.Cameras | Sort-Object Channel) {
                        if ($filterChannels -and $camera.Channel -notin $Channel) {
                            continue
                        }
                        if ($camera.Enabled -and $EnableFilter -eq 'Disabled') {
                            continue
                        }
                        if (-not $camera.Enabled -and $EnableFilter -eq 'Enabled') {
                            continue
                        }
                        if ($MyInvocation.BoundParameters.ContainsKey('Name') -and -not [string]::IsNullOrWhiteSpace($Name)) {
                            if ($camera.Name -like $Name) {
                                $camera
                                break
                            }
                        } else {
                            $camera
                        }
                    }
                }
            }

            'ById' {
                $site = Get-Site
                $serverId = $site.FQID.ServerId
                foreach ($guid in $Id) {
                    try {
                        $path = "Camera[$guid]"
                        $camera = [VideoOS.Platform.ConfigurationItems.Camera]::new($serverId, $path)
                        Write-Output $camera
                    } catch [VideoOS.Platform.PathNotFoundMIPException] {
                        if ($script:Messages) {
                            $message = $script:Messages.CameraOnSiteWithIdNotFound -f $site.Name, $guid
                            Write-Error -Message $message -Exception $_.Exception
                        } else {
                            Write-Error $_
                        }
                    }
                }
            }

            'BySearch' {
                $vmsVersion = [version](Get-Site).Properties['ServerVersion']
                if ($vmsVersion -ge '20.2') {
                    $nameFilter = [VideoOS.ConfigurationApi.ClientService.PropertyFilter]::new('Name', $Comparison, $Name)
                    $descriptionFilter = [VideoOS.ConfigurationApi.ClientService.PropertyFilter]::new('Description', $Comparison, $Description)
                    $cameraFilter = [VideoOS.ConfigurationApi.ClientService.ItemFilter]::new('Camera', @($nameFilter, $descriptionFilter), $EnableFilter)
                    $queryService = [VideoOS.ConfigurationApi.ClientService.QueryItems]::new((Get-Site).FQID.ServerId)
                    foreach ($result in $queryService.Query($cameraFilter, $MaxResults)) {
                        if ($MyInvocation.BoundParameters.ContainsKey('Channel') -and $result.Channel -notin $Channel) {
                            continue
                        }
                        $isDisabled = -not $enabledCameras.ContainsKey([guid]$result.Id)
                        if ($isDisabled -and $EnableFilter -in 'All', 'Disabled') {
                            $result
                        }
                        if (-not $isDisabled -and $EnableFilter -in 'All', 'Enabled') {
                            $result
                        }
                    }
                } else {
                    Write-Verbose "Falling back to slower hardware / camera enumeration since QueryItems is not available on VMS version $vmsVersion. This command will run faster on versions 2020 R2 or later."
                    $params = @{
                        EnableFilter = $EnableFilter
                    }
                    if ($MyInvocation.BoundParameters.ContainsKey('Channel')) {
                        $params.Channel = $Channel
                    }

                    switch ($Comparison) {
                        {$_ -in 'Equals', 'NotEquals' } {
                            $pattern = '^{0}$'
                        }
                        'Contains' {
                            $pattern = '{0}'
                        }
                        'BeginsWith' {
                            $pattern = '^{0}'
                        }
                        default {
                            throw "Comparison operator '$Comparison' not implemented."
                        }
                    }

                    Get-Hardware | Get-VmsCamera @params | Foreach-Object {
                        $camera = $_
                        if ([string]::IsNullOrWhiteSpace('Name') -and [string]::IsNullOrWhiteSpace('Description')) {
                            $camera
                            return
                        }

                        $isMatch = $true
                        foreach ($p in 'Name', 'Description') {
                            $parameterValue = (Get-Variable -Name $p -ErrorAction Ignore).Value
                            if ([string]::IsNullOrWhiteSpace($parameterValue)) {
                                continue
                            }
                            $regexPattern = $pattern -f [regex]::Escape($parameterValue)
                            $isMatch = $camera.$p -match $regexPattern
                            if ($Comparison -eq 'NotEquals') {
                                $isMatch = -not $isMatch
                            }
                            if (-not $isMatch) {
                                break
                            }
                        }
                        if ($isMatch) {
                            $camera
                        }
                    }
                }
            }
            Default {
                throw "ParameterSetName '$($PSCmdlet.ParameterSetName)' not implemented."
            }
        }
    }
}
function Get-VmsCameraGeneralSetting {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Camera[]]
        $Camera,

        [Parameter()]
        [switch]
        $RawValues,

        [Parameter()]
        [switch]
        $ValueTypeInfo
    )

    process {
        foreach ($cam in $Camera) {
            $generalSettings = $cam.DeviceDriverSettingsFolder.DeviceDriverSettings[0].DeviceDriverSettingsChildItem
            $parsedSettings = $generalSettings | ConvertFrom-ConfigChildItem -RawValues:$RawValues
            if ($ValueTypeInfo) {
                Write-Output $parsedSettings.ValueTypeInfo.Clone()
            } else {
                Write-Output $parsedSettings.Properties.Clone()
            }
        }
    }
}
function Get-VmsCameraStream {
    [CmdletBinding(DefaultParameterSetName = 'ByName')]
    [OutputType([VmsCameraStreamConfig])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Camera[]]
        $Camera,

        [Parameter(ParameterSetName = 'ByName')]
        [string]
        $Name,

        [Parameter(Mandatory, ParameterSetName = 'Enabled')]
        [switch]
        $Enabled,

        [Parameter(Mandatory, ParameterSetName = 'LiveDefault')]
        [switch]
        $LiveDefault,

        [Parameter(Mandatory, ParameterSetName = 'Recorded')]
        [switch]
        $Recorded,

        [Parameter()]
        [switch]
        $RawValues
    )

    process {
        foreach ($cam in $Camera) {
            $streamUsages = ($cam.StreamFolder.Streams | Select-Object -First 1).StreamUsageChildItems
            if ($null -eq $streamUsages) {
                $message = 'Camera "{0}" does not support simultaneous use of multiple streams. The following properties should be ignored for streams on this camera: DisplayName, Enabled, LiveMode, LiveDefault, Recorded.' -f $cam.Name
                Write-Warning $message
            }
            $deviceDriverSettings = $cam.DeviceDriverSettingsFolder.DeviceDriverSettings
            if ($null -eq $deviceDriverSettings -or $deviceDriverSettings.Count -eq 0 -or $deviceDriverSettings[0].StreamChildItems.Count -eq 0) {
                # Added this due to a situation where a camera/driver is in a weird state where maybe a replace hardware
                # is needed to bring it online and until then there are no stream settings listed in the settings tab
                # for the camera. This block allows us to return _something_ even though there are no stream settings available.
                $message = 'Camera "{0}" has no device driver settings available.' -f $cam.Name
                Write-Warning $message
                foreach ($streamUsage in $streamUsages) {
                    if ($LiveDefault -and -not $streamUsage.LiveDefault) {
                        continue
                    }
                    if ($Recorded -and -not $streamUsage.Record) {
                        continue
                    }
                    [VmsCameraStreamConfig]@{
                        Name              = $streamUsage.Name
                        DisplayName       = $streamUsage.Name
                        Enabled           = $true
                        LiveDefault       = $streamUsage.LiveDefault
                        LiveMode          = $streamUsage.LiveMode
                        Recorded          = $streamUsage.Record
                        Settings          = @{}
                        ValueTypeInfo     = @{}
                        Camera            = $cam
                        StreamReferenceId = $streamUsage.StreamReferenceId
                    }
                }

                continue
            }

            foreach ($stream in $deviceDriverSettings[0].StreamChildItems) {
                $streamUsage = if ($streamUsages)  { $streamUsages | Where-Object {$_.StreamReferenceId -eq $_.StreamReferenceIdValues[$stream.DisplayName] } }
                if ($LiveDefault -and -not $streamUsage.LiveDefault) {
                    continue
                }
                if ($Recorded -and -not $streamUsage.Record) {
                    continue
                }
                if ($Enabled -and $null -eq $streamUsage -and -not $null -eq $streamUsages) {
                    # Added "-not $null -eq $streamUsages" so that old cameras without multi-stream
                    # support still provide stream settings when the user uses the Enabled switch.
                    # Otherwise it might look like there are no settings available on those camera's
                    # streams.
                    continue
                }
                if ($MyInvocation.BoundParameters.ContainsKey('Name') -and $stream.DisplayName -notlike $Name) {
                    continue
                }
                $parsedSettings = $stream | ConvertFrom-ConfigChildItem -RawValues:$RawValues
                [VmsCameraStreamConfig]@{
                    Name              = $stream.DisplayName
                    DisplayName       = $streamUsage.Name
                    Enabled           = $null -ne $streamUsage
                    LiveDefault       = $streamUsage.LiveDefault
                    LiveMode          = $streamUsage.LiveMode
                    Recorded          = $streamUsage.Record
                    Settings          = $parsedSettings.Properties.Clone()
                    ValueTypeInfo     = $parsedSettings.ValueTypeInfo.Clone()
                    Camera            = $cam
                    StreamReferenceId = if ($null -ne $streamUsage) { $streamUsage.StreamReferenceId } else { [guid]::Empty }
                }
            }
        }
    }
}
function Get-VmsConnectionString {
    [CmdletBinding()]
    [Alias('Get-ConnectionString')]
    [OutputType([string])]
    param (
        [Parameter(Position = 0)]
        [string]
        $Component = 'ManagementServer'
    )

    process {
        if (Get-Item -Path HKLM:\SOFTWARE\VideoOS\Server\ConnectionString -ErrorAction Ignore) {
            Get-ItemPropertyValue -Path HKLM:\SOFTWARE\VideoOS\Server\ConnectionString -Name $Component
        } else {
            if ($Component -ne 'ManagementServer') {
                Write-Warning "Specifying a component name is only allowed on a management server running version 2022 R3 (22.3) or greater."
            }
            Get-ItemPropertyValue -Path HKLM:\SOFTWARE\VideoOS\Server\Common -Name 'Connectionstring'
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsConnectionString -ParameterName Component -ScriptBlock {
    $values = Get-Item HKLM:\SOFTWARE\videoos\Server\ConnectionString\ -ErrorAction Ignore | Select-Object -ExpandProperty Property
    if ($values) {
        Complete-SimpleArgument $args $values
    }
}
function Get-VmsDeviceGroup {
    [CmdletBinding(DefaultParameterSetName = 'ByName')]
    [Alias('Get-DeviceGroup')]
    param (
        [Parameter(ValueFromPipeline, ParameterSetName = 'ByName')]
        [ValidateScript({
                if ($_.GetType().Name -notmatch '^(Camera|Microphone|Speaker|InputEvent|Output|Metadata)Group$') {
                    throw "Unexpected object type: $($_.GetType().FullName)"
                }
                $true
            })]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $ParentGroup,

        [Parameter(Position = 0, ParameterSetName = 'ByName')]
        [string]
        $Name = '*',

        [Parameter(Mandatory, Position = 1, ParameterSetName = 'ByPath')]
        [string[]]
        $Path,

        [Parameter(Position = 2, ParameterSetName = 'ByName')]
        [Parameter(Position = 2, ParameterSetName = 'ByPath')]
        [Alias('DeviceCategory')]
        [ValidateSet('Camera', 'Microphone', 'Speaker', 'Input', 'Output', 'Metadata')]
        [string]
        $Type = 'Camera',

        [Parameter(ParameterSetName = 'ByName')]
        [Parameter(ParameterSetName = 'ByPath')]
        [switch]
        $Recurse
    )

    begin {
        $adjustedType = $Type
        if ($adjustedType -eq 'Input') {
            # Inputs on cameras have an object type called "InputEvent"
            # but we don't want the user to have to remember that. Besides,
            # inputs and events are two different things.
            $adjustedType = 'InputEvent'
        }
    }

    process {
        $rootGroup = Get-VmsManagementServer
        if ($ParentGroup) {
            $rootGroup = $ParentGroup
        }

        $matchFound = $false
        switch ($PSCmdlet.ParameterSetName) {
            'ByName' {
                $rootGroup."$($adjustedType)GroupFolder"."$($adjustedType)Groups" | Where-Object Name -like $Name | Foreach-Object {
                    $matchFound = $true
                    $_
                    if ($Recurse) {
                        $_ | Get-VmsDeviceGroup -Type $Type -Recurse
                    }
                }
            }

            'ByPath' {
                $params = @{
                    Type        = $Type
                    ErrorAction = 'SilentlyContinue'
                }
                $pathInterrupted = $false
                $pathParts = $Path | Split-VmsDeviceGroupPath
                foreach ($name in $pathParts) {
                    $params.Name = $name
                    $group = Get-VmsDeviceGroup @params
                    if ($null -eq $group) {
                        $pathInterrupted = $true
                        break
                    }
                    $params.ParentGroup = $group
                }
                if ($pathParts -and -not $pathInterrupted) {
                    $matchFound = $true
                    $params.ParentGroup
                    if ($Recurse) {
                        $params.ParentGroup | Get-VmsDeviceGroup -Recurse
                    }
                }
                if ($null -eq $pathParts -and $Recurse) {
                    Get-VmsDeviceGroup -Recurse
                }
            }
        }

        if (-not $matchFound -and -not [management.automation.wildcardpattern]::ContainsWildcardCharacters($Name)) {
            Write-Error "No $Type group found with the name '$Name'"
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsDeviceGroup -ParameterName Name -ScriptBlock {
    param (
        $commandName,
        $parameterName,
        $wordToComplete,
        $commandAst,
        $fakeBoundParameters
    )


    $adjustedType = if ($fakeBoundParameters.Type) { $fakeBoundParameters.Type } else { 'Camera' }
    if ($adjustedType -eq 'Input') {
        # Inputs on cameras have an object type called "InputEvent"
        # but we don't want the user to have to remember that. Besides,
        # inputs and events are two different things.
        $adjustedType = 'InputEvent'
    }
    $parentGroup = if ($fakeBoundParameters.ParentGroup) { $fakeBoundParameters.ParentGroup } else { Get-VmsManagementServer }
    $folder = $parentGroup."$($adjustedType)GroupFolder"
    $possibleValues = $folder."$($adjustedType)Groups".Name
    $wordToComplete = $wordToComplete.Trim("'").Trim('"')
    if (-not [string]::IsNullOrWhiteSpace($wordToComplete)) {
        $possibleValues = $possibleValues | Where-Object { $_ -like "$wordToComplete*" }
    }
    $possibleValues | Foreach-Object {
        if ($_ -like '* *') {
            "'$_'"
        } else {
            $_
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsDeviceGroup -ParameterName Path -ScriptBlock {
    param (
        $commandName,
        $parameterName,
        $wordToComplete,
        $commandAst,
        $fakeBoundParameters
    )

    $wordToComplete = $wordToComplete.Trim('"').Trim("'")
    $params = @{
        Type = if ($fakeBoundParameters.Type) { $fakeBoundParameters.Type } else { 'Camera' }
        Path = "{0}*" -f $wordToComplete
    }
    $groups = Get-VmsDeviceGroup @params
    if ($groups) {
        $possibleValues = $groups.Name | Foreach-Object {
            $_ -replace '(?<!`)/', '`/'
        }

        $parts = @("$wordToComplete*" | Split-VmsDeviceGroupPath)
        $parentPath = '/'
        for ($i = 0; $i -lt $parts.Count - 1; $i++) {
            $parentPath += '{0}/' -f ($parts[$i] -replace '(?<!`)/', '`/')
        }

        $possibleValues | Foreach-Object {
            $value = $parentPath + "$_"
            if ($value -like '* *') {
                "'$value'"
            } else {
                $value
            }
        }
    }
}
function Get-VmsDeviceGroupMember {
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline)]
        [ValidateScript({
            if ($_.GetType().Name -notmatch '^(Camera|Microphone|Speaker|InputEvent|Output|Metadata)Group$') {
                throw "Unexpected object type: $($_.GetType().FullName)"
            }
            $true
        })]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $Group,

        [Parameter()]
        [VideoOS.ConfigurationApi.ClientService.EnableFilter]
        $EnableFilter = [VideoOS.ConfigurationApi.ClientService.EnableFilter]::Enabled
    )

    process {
        foreach ($g in $Group) {
            $deviceType = ($g | ConvertTo-ConfigItemPath).ItemType -replace 'Group', ''
            $g."$($deviceType)Folder"."$($deviceType)s" | Foreach-Object {
                if ($_.Enabled -and $EnableFilter -eq 'Disabled') {
                    return
                }
                if (-not $_.Enabled -and $EnableFilter -eq 'Enabled') {
                    return
                }
                $_
            }
        }
    }
}
function Get-VmsDeviceStatus {
    <#
    .SYNOPSIS
        Gets the current device status for any streaming device types directly from the recording server(s).
    .DESCRIPTION
        Recording Servers offer a status interface called
        RecorderStatusService2. This service has a method called
        GetCurrentDeviceStatus which can return the current state of any
        streaming device type including cameras, microphones, speakers, and
        metadata, as well as IO device types including inputs and outputs.
 
        This cmdlet will return status for one or more of the streaming device
        types, and the results will include all devices of the specified
        type(s) that are active on the recording server(s).
    .EXAMPLE
        Get-VmsDeviceStatus -DeviceType Camera, Microphone
 
        Returns the status of all cameras and microphones on all recording
        servers
    .EXAMPLE
        Get-VmsDeviceStatus
 
        Returns the status of all cameras on all recording servers. The
        default DeviceType value is 'Cameras', so if that is all you need, you
        may omit the DeviceType parameter like this.
    .EXAMPLE
        Get-RecordingServer -Name 'Recorder1' | Get-VmsDeviceStatus
 
        Returns the status of all cameras on recording server named
        "Recorder1".
    .EXAMPLE
        Get-VmsDeviceStatus -Parallel
 
        Returns the status of all cameras on all recording servers, but runs
        status requests in parallel by recording server using the PoshRSJobs
        module.
    .OUTPUTS
        [VmsStreamDeviceStatus]
    #>

    [CmdletBinding()]
    [OutputType([VmsStreamDeviceStatus])]
    param(
        # Specifies one or more Recording Server ID's. Omit this parameter and
        # all recording servers will be queried for status.
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Id')]
        [guid[]]
        $RecordingServerId,

        # Specifies one or more streaming device types to retrieve status for.
        # Default is 'Camera'.
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [ValidateSet('Camera', 'Microphone', 'Speaker', 'Metadata', IgnoreCase = $false)]
        [string[]]
        $DeviceType = 'Camera'
    )

    begin {
        $scriptBlock = {
            param([guid]$RecorderId, [VideoOS.Platform.Item[]]$Devices, [type]$VmsStreamDeviceStatusClass)
            $recorderItem = [VideoOS.Platform.Configuration]::Instance.GetItem($RecorderId, [VideoOS.Platform.Kind]::Server)
            $svc = [VideoOS.Platform.SDK.Proxy.Status2.RecorderStatusService2]::new($recorderItem.FQID.ServerId.Uri)
            $status = @{}
            $currentStatus = $svc.GetCurrentDeviceStatus((Get-VmsToken), $Devices.FQID.ObjectId)
            foreach ($kind in 'Camera', 'Microphone', 'Speaker', 'Metadata') {
                foreach ($entry in $currentStatus."$($kind)DeviceStatusArray") {
                    $status[$entry.DeviceId] = $entry
                }
            }
            foreach ($item in $Devices) {
                $obj = $VmsStreamDeviceStatusClass::new($status[$item.FQID.ObjectId])
                $obj.DeviceName = $item.Name
                $obj.DeviceType = [VideoOS.Platform.Kind]::DefaultTypeToNameTable[$item.FQID.Kind]
                $obj.RecorderName = $recorderItem.Name
                $obj.RecorderId = $RecorderItem.FQID.ObjectId
                Write-Output $obj
            }
        }
    }

    process {
        <# TODO: Once a decision is made on how to handle the PoshRSJob
           dependency, uncomment the bits below and remove the line right
           after the opening foreach curly brace as it's already handled
           in the else block.
        #>

        $recorderCameraMap = Get-DevicesByRecorder -Id $RecordingServerId -DeviceType $DeviceType
        # $jobs = [system.collections.generic.list[RSJob]]::new()
        foreach ($recorderId in $recorderCameraMap.Keys) {
            $scriptBlock.Invoke($recorderId, $recorderCameraMap.$recorderId, ([VmsStreamDeviceStatus]))
            # if ($Parallel -and $RecordingServerId.Count -gt 1) {
            # $job = Start-RSJob -ScriptBlock $scriptBlock -ArgumentList $recorderId, $recorderCameraMap.$recorderId, ([VmsStreamDeviceStatus])
            # $jobs.Add($job)
            # } else {
            # $scriptBlock.Invoke($recorderId, $recorderCameraMap.$recorderId, ([VmsStreamDeviceStatus]))
            # }
        }
        # if ($jobs.Count -gt 0) {
        # $jobs | Wait-RSJob -ShowProgress:($ProgressPreference -eq 'Continue') | Receive-RSJob
        # $jobs | Remove-RSJob
        # }
    }
}
function Get-VmsFailoverGroup {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.FailoverGroup])]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [guid]
        $Id
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.2 -ErrorAction Stop
    }

    process {
        if ($Id) {
            $group = [VideoOS.Platform.ConfigurationItems.FailoverGroup]::new((Get-Site).FQID.ServerId, "FailoverGroup[$Id]")
            Write-Output $group
        }
        else {
            $ms = Get-VmsManagementServer
            Write-Output $ms.FailoverGroupFolder.FailoverGroups
        }
    }
}
function Get-VmsRecordingServer {
    [CmdletBinding(DefaultParameterSetName = 'ByName')]
    [Alias('Get-RecordingServer')]
    [OutputType([VideoOS.Platform.ConfigurationItems.RecordingServer])]
    param (
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'ByName')]
        [string]
        $Name = '*',

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ById')]
        [guid]
        $Id,

        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'ByHostname')]
        [Alias('ComputerName')]
        [string]
        $HostName = '*'
    )

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'ByName' {
                $matchFound = $false
                foreach ($rec in (Get-VmsManagementServer).RecordingServerFolder.RecordingServers | Where-Object Name -like $Name) {
                    $matchFound = $true
                    $rec
                }
                if (-not $matchFound -and -not [system.management.automation.wildcardpattern]::ContainsWildcardCharacters($Name)) {
                    Write-Error "No item found with name matching '$Name'"
                }
            }
            'ById' {
                try {
                    [VideoOS.Platform.ConfigurationItems.RecordingServer]::new((Get-VmsManagementServer).ServerId, "RecordingServer[$Id]")
                }
                catch [VideoOS.Platform.PathNotFoundMIPException] {
                    Write-Error -Message "No item found with id matching '$Id'" -Exception $_.Exception
                }
            }
            'ByHostname' {
                $matchFound = $false
                foreach ($rec in (Get-VmsManagementServer).RecordingServerFolder.RecordingServers | Where-Object HostName -like $HostName) {
                    $matchFound = $true
                    $rec
                }
                if (-not $matchFound -and -not [system.management.automation.wildcardpattern]::ContainsWildcardCharacters($HostName)) {
                    Write-Error "No item found with hostname matching '$HostName'"
                }
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsRecordingServer -ParameterName Name -ScriptBlock {
    $values = (Get-VmsRecordingServer).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Get-VmsRecordingServer -ParameterName HostName -ScriptBlock {
    $values = (Get-VmsRecordingServer).HostName | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Get-VmsRecordingServer -ParameterName Id -ScriptBlock {
    $values = (Get-VmsRecordingServer | Sort-Object Name).Id
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsRole {
    [CmdletBinding(DefaultParameterSetName = 'ByName')]
    [Alias('Get-Role')]
    [OutputType([VideoOS.Platform.ConfigurationItems.Role])]
    param (
        [Parameter(Position = 0, ValueFromPipelineByPropertyName, ParameterSetName = 'ByName')]
        [string]
        $Name = '*',

        [Parameter(ParameterSetName = 'ByName')]
        [string]
        $RoleType = '*',

        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'ById')]
        [Alias('RoleId')]
        [guid]
        $Id
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ById') {
            try {
                [VideoOS.Platform.ConfigurationItems.Role]::new((Get-VmsManagementServer).ServerId, "Role[$Id]")
            }
            catch [VideoOS.Platform.PathNotFoundMIPException] {
                Write-Error -Message "No item found with ID matching $Id" -Exception $_.Exception
            }
        }
        else {
            $matchFound = $false
            foreach ($role in (Get-VmsManagementServer).RoleFolder.Roles) {
                if ($role.Name -notlike $Name -or $role.RoleType -notlike $RoleType) {
                    continue
                }
                $role
                $matchFound = $true
            }
            if (-not $matchFound -and -not [management.automation.wildcardpattern]::ContainsWildcardCharacters($Name)) {
                Write-Error "Role '$Name' not found."
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsRole -ParameterName Name -ScriptBlock {
    $values = (Get-VmsRole).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Get-VmsRole -ParameterName Id -ScriptBlock {
    $values = (Get-VmsRole | Sort-Object Name).Id
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Get-VmsRole -ParameterName RoleType -ScriptBlock {
    $values = (Get-VmsRole | Select-Object -First 1 | Select-Object -ExpandProperty RoleTypeValues).Values | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsRoleMember {
    [CmdletBinding()]
    [Alias('Get-User')]
    [OutputType([VideoOS.Platform.ConfigurationItems.User])]
    param (
        [Parameter(ValueFromPipeline, Position = 0)]
        [Alias('RoleName')]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role[]]
        $Role
    )

    process {
        if ($null -eq $Role) {
            $Role = Get-VmsRole
        }
        foreach ($record in $Role) {
            foreach ($user in $record.UserFolder.Users) {
                $user
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsRoleMember -ParameterName Role -ScriptBlock {
    $values = (Get-VmsRole).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsRoleOverallSecurity {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Alias('RoleName')]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role]
        $Role,

        [Parameter()]
        [SecurityNamespaceTransformAttribute()]
        [string[]]
        $SecurityNamespace
    )

    process {
        if ($Role.RoleType -ne 'UserDefined') {
            Write-Error "Overall security settings do not apply to the Administrator role."
            return
        }

        try {
            $invokeInfo = $Role.ChangeOverallSecurityPermissions()
            if ($SecurityNamespace.Count -eq 0) {
                $SecurityNamespace = $invokeInfo.SecurityNamespaceValues.Keys
            }
            foreach ($namespace in $SecurityNamespace) {
                $namespaceId = [guid]::Empty
                if (-not [guid]::TryParse($namespace, [ref]$namespaceId)) {
                    $namespaceId = $invokeInfo.SecurityNamespaceValues[$namespace]
                    if ([string]::IsNullOrWhiteSpace($namespaceId)) {
                        Write-Error "SecurityNamespace '$namespace' not found."
                        return
                    }
                }
                $response = $Role.ChangeOverallSecurityPermissions($namespaceId)
                $result = @{
                    Role        = $Role.Path
                    DisplayName = $invokeInfo.SecurityNamespaceValues.Keys | Where-Object { $invokeInfo.SecurityNamespaceValues[$_] -eq $namespaceId }
                }
                foreach ($key in $response.GetPropertyKeys()) {
                    $result[$key] = $response.GetProperty($key)
                }
                $result
            }
        }
        catch {
            Write-Error -ErrorRecord $_
        }
    }
}


Register-ArgumentCompleter -CommandName Get-VmsRoleOverallSecurity -ParameterName Role -ScriptBlock {
    $values = (Get-VmsRole).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Get-VmsRoleOverallSecurity -ParameterName SecurityNamespace -ScriptBlock {
    $values = (Get-VmsRole -RoleType UserDefined).ChangeOverallSecurityPermissions().SecurityNamespaceValues.Keys | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsSiteInfo {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateScript({ ValidateSiteInfoTagName @args })]
        [SupportsWildcards()]
        [string]
        $Property = '*'
    )

    begin {
        Assert-VmsVersion -MinimumVersion 20.2 -Comment 'Support reading and modifying Site Information was introduced in XProtect 2020 R2.' -ErrorAction Stop
    }

    process {
        $ownerPath = 'BasicOwnerInformation[{0}]' -f (Get-VmsManagementServer).Id
        $ownerInfo = Get-ConfigurationItem -Path $ownerPath
        $resultFound = $false
        foreach ($p in $ownerInfo.Properties) {
            if ($p.Key -match '^\[(?<id>[a-fA-F0-9\-]{36})\]/(?<tagtype>[\w\.]+)$') {
                if ($Matches.tagtype -like $Property) {
                    $resultFound = $true
                    [pscustomobject]@{
                        DisplayName  = $p.DisplayName
                        Property   = $Matches.tagtype
                        Value = $p.Value
                    }
                }
            } else {
                Write-Warning "Site information property key format unrecognized: $($p.Key)"
            }
        }
        if (-not $resultFound -and -not [system.management.automation.wildcardpattern]::ContainsWildcardCharacters($Property)) {
            Write-Error "Site information property with key '$Property' not found."
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsSiteInfo -ParameterName Property -ScriptBlock { OwnerInfoPropertyCompleter @args }
function Get-VmsStorageRetention {
    [CmdletBinding()]
    [OutputType([timespan])]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
        [StorageNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Storage[]]
        $Storage
    )

    process {
        if ($Storage.Count -lt 1) {
            $Storage = Get-VmsStorage
        }
        foreach ($s in $Storage) {
            $retention = [int]$s.RetainMinutes
            foreach ($archive in $s.ArchiveStorageFolder.ArchiveStorages) {
                if ($archive.RetainMinutes -gt $retention) {
                    $retention = $archive.RetainMinutes
                }
            }
            [timespan]::FromMinutes($retention)
        }
    }
}


Register-ArgumentCompleter -CommandName Get-VmsStorageRetention -ParameterName Storage -ScriptBlock {
    $values = (Get-VmsRecordingServer | Get-VmsStorage).Name | Select-Object -Unique | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsToken {
    [CmdletBinding()]
    [Alias('Get-Token')]
    param (
    )

    process {
        [VideoOS.Platform.Login.LoginSettingsCache]::GetLoginSettings((Get-Site).FQID).Token
    }
}
function Get-VmsView {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    [OutputType([VideoOS.Platform.ConfigurationItems.View])]
    param (
        [Parameter(ValueFromPipeline, ParameterSetName = 'Default')]
        [VideoOS.Platform.ConfigurationItems.ViewGroup[]]
        $ViewGroup,

        [Parameter(ParameterSetName = 'Default', Position = 1)]
        [ValidateNotNullOrEmpty()]
        [SupportsWildcards()]
        [string[]]
        $Name = '*',

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ById', Position = 2)]
        [guid]
        $Id
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Default' {
                if ($null -eq $ViewGroup) {
                    $ViewGroup = Get-VmsViewGroup -Recurse
                }
                $count = 0
                foreach ($vg in $ViewGroup) {
                    foreach ($view in $vg.ViewFolder.Views) {
                        if ($view.Path -in $vg.ViewGroupFolder.ViewGroups.ViewFolder.Views.Path) {
                            # TODO: Remove this someday when bug 479533 is no longer an issue.
                            Write-Verbose "Ignoring duplicate view caused by configuration api issue resolved in later VMS versions."
                            continue
                        }
                        foreach ($n in $Name) {
                            if ($view.DisplayName -like $n) {
                                Write-Output $view
                                $count++
                            }
                        }
                    }
                }

                if ($count -eq 0 -and -not [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($Name)) {
                    Write-Error "View ""$Name"" not found."
                }
            }

            'ById' {
                $path = 'View[{0}]' -f $Id.ToString().ToUpper()
                Write-Output ([VideoOS.Platform.ConfigurationItems.View]::new((Get-Site).FQID.ServerId, $path))
            }
        }
    }
}

function ViewArgumentCompleter{
    param ( $commandName,
            $parameterName,
            $wordToComplete,
            $commandAst,
            $fakeBoundParameters )

    if ($fakeBoundParameters.ContainsKey('ViewGroup')) {
        $folder = $fakeBoundParameters.ViewGroup.ViewFolder
        $possibleValues = $folder.Views.Name
        $wordToComplete = $wordToComplete.Trim("'").Trim('"')
        if (-not [string]::IsNullOrWhiteSpace($wordToComplete)) {
            $possibleValues = $possibleValues | Where-Object { $_ -like "$wordToComplete*" }
        }
        $possibleValues | Foreach-Object {
            if ($_ -like '* *') {
                "'$_'"
            } else {
                $_
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsView -ParameterName Name -ScriptBlock (Get-Command ViewArgumentCompleter).ScriptBlock
function Get-VmsViewGroup {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.ViewGroup])]
    param (
        [Parameter(ValueFromPipeline, ParameterSetName = 'Default')]
        [VideoOS.Platform.ConfigurationItems.ViewGroup]
        $Parent,

        [Parameter(ParameterSetName = 'Default', Position = 1)]
        [ValidateNotNullOrEmpty()]
        [SupportsWildcards()]
        [string[]]
        $Name = '*',

        [Parameter(ParameterSetName = 'Default')]
        [switch]
        $Recurse,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ById', Position = 2)]
        [guid]
        $Id
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ById') {
            try {
                $vg = [VideoOS.Platform.ConfigurationItems.ViewGroup]::new((Get-Site).FQID.ServerId, "ViewGroup[$Id]")
                Write-Output $vg
            } catch [System.Management.Automation.MethodInvocationException] {
                if ($_.FullyQualifiedErrorId -eq 'PathNotFoundMIPException') {
                    Write-Error "No ViewGroup found with ID matching $Id"
                    return
                }
            }
        } else {
            if ($null -ne $Parent) {
                $vgFolder = $Parent.ViewGroupFolder
            } else {
                $vgFolder = (Get-VmsManagementServer).ViewGroupFolder
            }

            $count = 0
            foreach ($vg in $vgFolder.ViewGroups) {
                foreach ($n in $Name) {
                    if ($vg.DisplayName -notlike $n) {
                        continue
                    }
                    $count++
                    if (-not $Recurse -or ($Recurse -and $Name -eq '*')) {
                        Write-Output $vg
                    }
                    if ($Recurse) {
                        $vg | Get-VmsViewGroup -Recurse
                    }
                    continue
                }
            }

            if ($count -eq 0 -and -not [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($Name)) {
                Write-Error "ViewGroup ""$Name"" not found."
            }
        }
    }
}

function ViewGroupArgumentCompleter{
    param ( $commandName,
            $parameterName,
            $wordToComplete,
            $commandAst,
            $fakeBoundParameters )

    $folder = (Get-VmsManagementServer).ViewGroupFolder
    if ($fakeBoundParameters.ContainsKey('Parent')) {
        $folder = $fakeBoundParameters.Parent.ViewGroupFolder
    }

    $possibleValues = $folder.ViewGroups.DisplayName
    $wordToComplete = $wordToComplete.Trim("'").Trim('"')
    if (-not [string]::IsNullOrWhiteSpace($wordToComplete)) {
        $possibleValues = $possibleValues | Where-Object { $_ -like "$wordToComplete*" }
    }
    $possibleValues | Foreach-Object {
        if ($_ -like '* *') {
            "'$_'"
        } else {
            $_
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsViewGroup -ParameterName Name -ScriptBlock (Get-Command ViewGroupArgumentCompleter).ScriptBlock
function Get-VmsViewGroupAcl {
    [CmdletBinding()]
    [OutputType([VmsViewGroupAcl])]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [VideoOS.Platform.ConfigurationItems.ViewGroup]
        $ViewGroup,

        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'FromRole')]
        [VideoOS.Platform.ConfigurationItems.Role[]]
        $Role,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'FromRoleId')]
        [VideoOS.Platform.ConfigurationItems.Role]
        $RoleId,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'FromRoleName')]
        [string]
        $RoleName
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'FromRole' { }
            'FromRoleId' { $Role = Get-VmsRole -Id $RoleId -ErrorAction Stop }
            'FromRoleName' { $Role = Get-VmsRole -Name $RoleName -ErrorAction Stop }
            Default { throw "Unexpected ParameterSetName ""$($PSCmdlet.ParameterSetName)""" }
        }
        if ($Role.Count -eq 0) {
            $Role = Get-VmsRole -RoleType UserDefined
        }
        foreach ($r in $Role) {
            $invokeInfo = $ViewGroup.ChangeSecurityPermissions($r.Path)
            if ($null -eq $invokeInfo) {
                Write-Error "Permissions can not be read or modified on view group ""$($ViewGroup.DisplayName)""."
                continue
            }
            $acl = [VmsViewGroupAcl]@{
                Role = $r
                Path = $ViewGroup.Path
                SecurityAttributes = @{}
            }
            foreach ($key in $invokeInfo.GetPropertyKeys()) {
                if ($key -eq 'UserPath') { continue }
                $acl.SecurityAttributes[$key] = $invokeInfo.GetProperty($key)
            }
            Write-Output $acl
        }
    }
}
function Import-VmsHardware {
    [CmdletBinding(DefaultParameterSetName = 'ImportHardware', SupportsShouldProcess)]
    param (
        [Parameter(ValueFromPipeline, ParameterSetName = 'ImportHardware')]
        [RecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.RecordingServer]
        $RecordingServer,

        [Parameter(Mandatory, ParameterSetName = 'ImportHardware')]
        [Parameter(Mandatory, ParameterSetName = 'SaveTemplate')]
        [string]
        $Path,

        [Parameter(Mandatory, ParameterSetName = 'SaveTemplate')]
        [switch]
        $SaveTemplate,

        [Parameter(ParameterSetName = 'SaveTemplate')]
        [switch]
        $Minimal
    )

    process {
        if ($SaveTemplate) {
            if (Test-Path -Path $Path) {
                Write-Error "There is already a file at $Path"
                return
            }
            if ($Minimal) {
                $rows = @(
                    [pscustomobject]@{
                        Address = 'http://192.168.1.100'
                        UserName = 'root'
                        Password = 'pass'
                        Description = 'This description column is optional and only present in this template as a place give you additional information. Feel free to delete this column.'
                    },
                    [pscustomobject]@{
                        Address = 'https://camera2.milestone.internal'
                        UserName = 'root'
                        Password = 'pass'
                        Description = 'In this row we use HTTPS instead of HTTP. If your camera is setup for HTTPS, this is how you tell Milestone to add the camera over a secure connection.'
                    },
                    [pscustomobject]@{
                        Address = '192.168.1.101'
                        UserName = 'root'
                        Password = 'pass'
                        Description = 'In this row we provide only the IP address. When you do this, the server assumes you want to use HTTP port 80.'
                    }
                )

                $rows | Export-Csv -Path $Path -NoTypeInformation
            }
            else {
                $rows = @(
                    [pscustomobject]@{
                        Address = 'http://192.168.1.100'
                        UserName = 'root'
                        Password = 'pass'
                        UserName2 = 'admin'
                        Password2 = 'admin'
                        UserName3 = 'service'
                        Password3 = '123456'
                        DriverNumber = ''
                        DriverFamily = ''
                        StorageName = ''
                        HardwareName = ''
                        Coordinates = ''

                        CameraName = ''
                        MicrophoneName = ''
                        SpeakerName = ''
                        MetadataName = ''
                        InputName = ''
                        OutputName = ''

                        EnabledCameraChannels = ''
                        EnabledMicrophoneChannels = ''
                        EnabledSpeakerChannels = ''
                        EnabledMetadataChannels = ''
                        EnabledInputChannels = ''
                        EnabledOutputChannels = ''

                        CameraGroup = $null
                        MicrophoneGroup = $null
                        SpeakerGroup = $null
                        MetadataGroup = $null
                        InputGroup = $null
                        OutputGroup = $null

                        RecordingServer = $null
                        UseDefaultCredentials = $false
                        Description = 'This camera will be scanned using three different sets of credentials and since no driver, or driver family name was provided, the camera will be scanned using all drivers. All values are left empty so only the first camera channel will be enabled, and the hardware name and child device names will be left at defaults.'
                    },
                    [pscustomobject]@{
                        Address = 'https://192.168.1.101'
                        UserName = 'root'
                        Password = 'pass'
                        UserName2 = 'admin'
                        Password2 = 'admin'
                        UserName3 = 'service'
                        Password3 = '123456'
                        DriverNumber = ''
                        DriverFamily = ''
                        StorageName = '90 Day Storage'
                        HardwareName = ''
                        Coordinates = ''

                        CameraName = ''
                        MicrophoneName = ''
                        SpeakerName = ''
                        MetadataName = ''
                        InputName = ''
                        OutputName = ''

                        EnabledCameraChannels = ''
                        EnabledMicrophoneChannels = ''
                        EnabledSpeakerChannels = ''
                        EnabledMetadataChannels = ''
                        EnabledInputChannels = ''
                        EnabledOutputChannels = ''

                        CameraGroup = $null
                        MicrophoneGroup = $null
                        SpeakerGroup = $null
                        MetadataGroup = $null
                        InputGroup = $null
                        OutputGroup = $null

                        RecordingServer = $null
                        UseDefaultCredentials = $false
                        Description = 'Same as the previous row, except this camera will be added over HTTPS port 443 and recorded to a storage configuration named "90 Day Storage"'
                    },
                    [pscustomobject]@{
                        Address = 'https://camera3.milestone.internal:8443'
                        UserName = 'root'
                        Password = 'pass'
                        UserName2 = 'admin'
                        Password2 = 'admin'
                        UserName3 = 'service'
                        Password3 = '123456'
                        DriverNumber = ''
                        DriverFamily = ''
                        StorageName = 'Invalid Storage Name'
                        HardwareName = ''
                        Coordinates = ''

                        CameraName = ''
                        MicrophoneName = ''
                        SpeakerName = ''
                        MetadataName = ''
                        InputName = ''
                        OutputName = ''

                        EnabledCameraChannels = ''
                        EnabledMicrophoneChannels = ''
                        EnabledSpeakerChannels = ''
                        EnabledMetadataChannels = ''
                        EnabledInputChannels = ''
                        EnabledOutputChannels = ''

                        CameraGroup = $null
                        MicrophoneGroup = $null
                        SpeakerGroup = $null
                        MetadataGroup = $null
                        InputGroup = $null
                        OutputGroup = $null

                        RecordingServer = $null
                        UseDefaultCredentials = $false
                        Description = 'Same as the previous row, except this camera will be added over HTTPS port 8443 and a dns name is used instead of an IP address. Since the StorageName value doesn''t match the name of a storage configuration on the recording server, the camera will record to default storage.'
                    },
                    [pscustomobject]@{
                        Address = '192.168.1.103'
                        UserName = 'root'
                        Password = 'pass'
                        UserName2 = ''
                        Password2 = ''
                        UserName3 = ''
                        Password3 = ''
                        DriverNumber = '713;710'
                        DriverFamily = ''
                        StorageName = ''
                        HardwareName = ''
                        Coordinates = ''

                        CameraName = ''
                        MicrophoneName = ''
                        SpeakerName = ''
                        MetadataName = ''
                        InputName = ''
                        OutputName = ''

                        EnabledCameraChannels = ''
                        EnabledMicrophoneChannels = ''
                        EnabledSpeakerChannels = ''
                        EnabledMetadataChannels = ''
                        EnabledInputChannels = ''
                        EnabledOutputChannels = ''

                        CameraGroup = $null
                        MicrophoneGroup = $null
                        SpeakerGroup = $null
                        MetadataGroup = $null
                        InputGroup = $null
                        OutputGroup = $null

                        RecordingServer = $null
                        UseDefaultCredentials = $true
                        Description = 'This camera will be added using HTTP port 80 since the address was not provided in the form of a URI. Since two DriverNumber values are present, the camera will be scanned against two drivers to see which one is best for the camera. Also since UseDefaultCredentials is true, the driver default credentials will be tried in addition to the user-supplied credentials.'
                    },
                    [pscustomobject]@{
                        Address = '192.168.1.104'
                        UserName = 'root'
                        Password = 'pass'
                        UserName2 = ''
                        Password2 = ''
                        UserName3 = ''
                        Password3 = ''
                        DriverNumber = ''
                        DriverFamily = 'Axis;Bosch'
                        StorageName = ''
                        HardwareName = ''
                        Coordinates = ''

                        CameraName = ''
                        MicrophoneName = ''
                        SpeakerName = ''
                        MetadataName = ''
                        InputName = ''
                        OutputName = ''

                        EnabledCameraChannels = ''
                        EnabledMicrophoneChannels = ''
                        EnabledSpeakerChannels = ''
                        EnabledMetadataChannels = ''
                        EnabledInputChannels = ''
                        EnabledOutputChannels = ''

                        CameraGroup = $null
                        MicrophoneGroup = $null
                        SpeakerGroup = $null
                        MetadataGroup = $null
                        InputGroup = $null
                        OutputGroup = $null

                        RecordingServer = $null
                        UseDefaultCredentials = $false
                        Description = 'This camera will be scanned against all Axis and Bosch device driver to find the best match.'
                    },
                    [pscustomobject]@{
                        Address = '192.168.1.105'
                        UserName = 'root'
                        Password = 'pass'
                        UserName2 = ''
                        Password2 = ''
                        UserName3 = ''
                        Password3 = ''
                        DriverNumber = '5000'
                        DriverFamily = ''
                        StorageName = ''
                        HardwareName = 'Parking (192.168.1.105)'
                        Coordinates = ''

                        CameraName = 'Parking East;Parking West'
                        MicrophoneName = ''
                        SpeakerName = ''
                        MetadataName = ''
                        InputName = ''
                        OutputName = ''

                        EnabledCameraChannels = '0;1'
                        EnabledMicrophoneChannels = ''
                        EnabledSpeakerChannels = ''
                        EnabledMetadataChannels = ''
                        EnabledInputChannels = ''
                        EnabledOutputChannels = ''

                        CameraGroup = $null
                        MicrophoneGroup = $null
                        SpeakerGroup = $null
                        MetadataGroup = $null
                        InputGroup = $null
                        OutputGroup = $null

                        RecordingServer = $null
                        UseDefaultCredentials = $false
                        Description = 'This camera will be added using the StableFPS driver using driver ID 5000, and the first two camera channels will be enabled. The hardware and two first camera channels will have user-supplied names from the CSV while the rest of the devices will have default names and will remain disabled.'
                    },
                    [pscustomobject]@{
                        Address = '192.168.1.106'
                        UserName = 'root'
                        Password = 'pass'
                        UserName2 = ''
                        Password2 = ''
                        UserName3 = ''
                        Password3 = ''
                        DriverNumber = '5000'
                        DriverFamily = ''
                        StorageName = ''
                        HardwareName = 'Reception'
                        Coordinates = ''

                        CameraName = 'Reception - Front Desk'
                        MicrophoneName = ''
                        SpeakerName = ''
                        MetadataName = ''
                        InputName = ''
                        OutputName = ''

                        EnabledCameraChannels = 'All'
                        EnabledMicrophoneChannels = ''
                        EnabledSpeakerChannels = ''
                        EnabledMetadataChannels = ''
                        EnabledInputChannels = ''
                        EnabledOutputChannels = ''

                        CameraGroup = '/Main Office/Reception'
                        MicrophoneGroup = $null
                        SpeakerGroup = $null
                        MetadataGroup = $null
                        InputGroup = $null
                        OutputGroup = $null

                        RecordingServer = $null
                        UseDefaultCredentials = $false
                        Description = 'This camera will be added with all camera channels enabled, and all other child devices will be disabled. The first camera channel will have a custom name and any additional channels will have the default name. A camera group path was provided, so if the top level group "Main Office" or subgroup "Reception" do not exist, they will be created, and the enabled cameras will be placed into the Reception subgroup.'
                    },
                    [pscustomobject]@{
                        Address = '192.168.1.107'
                        UserName = 'root'
                        Password = 'pass'
                        UserName2 = ''
                        Password2 = ''
                        UserName3 = ''
                        Password3 = ''
                        DriverNumber = '5000'
                        DriverFamily = ''
                        StorageName = ''
                        HardwareName = 'Warehouse (192.168.1.107)'
                        Coordinates = ''

                        CameraName = 'Warehouse Overview;;Warehouse 180'
                        MicrophoneName = ''
                        SpeakerName = ''
                        MetadataName = ''
                        InputName = ''
                        OutputName = ''

                        EnabledCameraChannels = '0;2'
                        EnabledMicrophoneChannels = ''
                        EnabledSpeakerChannels = ''
                        EnabledMetadataChannels = ''
                        EnabledInputChannels = ''
                        EnabledOutputChannels = ''

                        CameraGroup = '/New cameras'
                        MicrophoneGroup = $null
                        SpeakerGroup = $null
                        MetadataGroup = $null
                        InputGroup = $null
                        OutputGroup = $null

                        RecordingServer = $null
                        UseDefaultCredentials = $false
                        Description = 'This camera will be added with the first and third channels enabled. Channels are counted from 0, so channel 0 represents "Camera 1". Note how in the CameraName column if you split by the semicolon symbol, the second entry is empty. This means the second camera channel will not be renamed from the default, but the first and third channels will be.'
                    },
                    [pscustomobject]@{
                        Address = '192.168.1.107'
                        UserName = 'root'
                        Password = 'pass'
                        UserName2 = ''
                        Password2 = ''
                        UserName3 = ''
                        Password3 = ''
                        DriverNumber = '5000'
                        DriverFamily = ''
                        StorageName = ''
                        HardwareName = 'Warehouse (192.168.1.107)'
                        Coordinates = '47.620, -122.349'

                        CameraName = ''
                        MicrophoneName = ''
                        SpeakerName = ''
                        MetadataName = ''
                        InputName = ''
                        OutputName = ''

                        EnabledCameraChannels = ''
                        EnabledMicrophoneChannels = ''
                        EnabledSpeakerChannels = ''
                        EnabledMetadataChannels = ''
                        EnabledInputChannels = ''
                        EnabledOutputChannels = ''

                        CameraGroup = '/New cameras'
                        MicrophoneGroup = $null
                        SpeakerGroup = $null
                        MetadataGroup = $null
                        InputGroup = $null
                        OutputGroup = $null

                        RecordingServer = 'Recorder-10'
                        UseDefaultCredentials = $false
                        Description = 'This row has a recording server display name specified which will override the recording server specified in the RecordingServer parameter of Import-VmsHardware. It also has GPS coordinates in lat, long format so all enabled devices will have the GisPoint property updated.'
                    }
                )
                $rows | Export-Csv -Path $Path -NoTypeInformation
            }
            return
        }
        try {
            Get-Site -ErrorAction Stop | Select-Site
        }
        catch {
            throw
        }
        $initialProgressPreference = $ProgressPreference
        try {
            $ProgressPreference = [System.Management.Automation.ActionPreference]::SilentlyContinue
            $tasks = New-Object System.Collections.Generic.List[object]
            $rows = Import-Csv -Path $Path
            $recorderCache = @{}
            $recorderPathMap = @{}
            $addressRowMap = @{}
            $addHardwareParams = [system.collections.generic.list[hashtable]]::new()
            Get-RecordingServer | Foreach-Object { $recorderCache.($_.Name) = $_; $recorderPathMap.($_.Path) = $_ }
            foreach ($row in $rows) {
                # Normalize the address format to "http://host/"
                $uriBuilder = [uribuilder]$row.Address
                $uriBuilder.Path = '/'
                $uriBuilder.Query = ''
                $row.Address = $uriBuilder.Uri

                $recorder = $RecordingServer
                if (-not [string]::IsNullOrWhiteSpace($row.RecordingServer)) {
                    if (-not $recorderCache.ContainsKey($row.RecordingServer)) {
                        Write-Error "Recording Server with display name '$($row.RecordingServer)' not found. Entry '$($row.HardwareName)' with address $($row.Address) will be skipped."
                        continue
                    }
                    $recorder = $recorderCache.($row.RecordingServer)
                }
                $addressRowMap.($row.Address.ToString()) = $row
                $credentials = New-Object System.Collections.Generic.List[pscredential]
                if (-not [string]::IsNullOrWhiteSpace($row.UserName) -and -not [string]::IsNullOrWhiteSpace($row.Password)) {
                    $credentials.Add([pscredential]::new($row.UserName, ($row.Password | ConvertTo-SecureString -AsPlainText -Force)))
                }
                if (-not [string]::IsNullOrWhiteSpace($row.UserName2) -and -not [string]::IsNullOrWhiteSpace($row.Password2)) {
                    $credentials.Add([pscredential]::new($row.UserName2, ($row.Password2 | ConvertTo-SecureString -AsPlainText -Force)))
                }
                if (-not [string]::IsNullOrWhiteSpace($row.UserName3) -and -not [string]::IsNullOrWhiteSpace($row.Password3)) {
                    $credentials.Add([pscredential]::new($row.UserName3, ($row.Password3 | ConvertTo-SecureString -AsPlainText -Force)))
                }

                $scanParams = @{
                    RecordingServer = $recorder
                    Address = $row.Address
                    Credential = $credentials
                    DriverNumber = $row.DriverNumber -split ';' | Where-Object { ![string]::IsNullOrWhiteSpace($_) -and $_ -as [int] }
                    DriverFamily = $row.DriverFamily -split ';' | Where-Object { ![string]::IsNullOrWhiteSpace($_) }
                    UseDefaultCredentials = if ($credentials.Count -eq 0) { 'True' } else { $row.UseDefaultCredentials -eq 'True' }
                    PassThru = $true
                }
                if ($scanParams.DriverNumber.Count -eq 1 -and $scanParams.Credential.Count -eq 1) {
                    Write-Verbose "Hardware driver and credential provided for $($row.Address). Skipping hardware scan."
                    $addHardwareParams.Add(@{
                        RecordingServer    = $scanParams.RecordingServer
                        HardwareAddress    = $scanParams.Address
                        DriverNumber       = $scanParams.DriverNumber
                        Credential         = $scanParams.Credential[0]
                    })
                    continue
                }

                if ($PSCmdlet.ShouldProcess("$($scanParams.Address) from recording server $($scanParams.RecordingServer.Name)", "Running Start-VmsHardwareScan")) {
                    Start-VmsHardwareScan @scanParams | Foreach-Object {
                        $tasks.Add($_)
                    }
                }
            }
            $ProgressPreference = $initialProgressPreference

            if ($tasks.Count -gt 0) {
                Wait-VmsTask -Path $tasks.Path -Title "Scanning hardware" -Cleanup | Foreach-Object {
                    $results = if ($_.Children.Count -gt 0) { [VmsHardwareScanResult[]]$_.Children } else { [VmsHardwareScanResult]$_ }
                    foreach ($result in $results) {
                        $result.RecordingServer = $recorderPathMap.($_.ParentPath)
                        # TODO: Remove this entire if block when bug 487881 is fixed and hotfixes for supported versions are available.
                        if ($result.MacAddressExistsLocal) {
                            if ($result.MacAddress -notin ($result.RecordingServer | Get-Hardware | Get-HardwareSetting).MacAddress) {
                                Write-Verbose "MacAddress $($result.MacAddress) incorrectly reported as already existing on recorder. Changing MacAddressExistsLocal to false."
                                $result.MacAddressExistsLocal = $false
                            }
                        }
                        $addHardwareParams.Add(@{
                            RecordingServer    = $result.RecordingServer
                            HardwareAddress    = $result.HardwareAddress
                            HardwareDriverPath = $result.HardwareDriverPath
                            Credential         = [pscredential]::new($result.UserName, ($result.Password | ConvertTo-SecureString -AsPlainText -Force))
                        })
                    }
                }
            }

            $deviceGroupCache = @{
                Camera = @{}
                Microphone = @{}
                Speaker = @{}
                Input = @{}
                Output = @{}
                Metadata = @{}
            }

            $recorderByPath = @{}
            $storageCache = @{}
            if ($PSCmdlet.ShouldProcess("Milestone XProtect site '$((Get-Site).Name)'", "Add-VmsHardware")) {
                foreach ($params in $addHardwareParams) {
                    Add-VmsHardware @params -Force -SkipConfig | Foreach-Object {
                        $stopwatch = [diagnostics.stopwatch]::StartNew()
                        $hardware = $_
                        $hardware.Enabled = $true

                        $row = $addressRowMap.($hardware.Address)
                        if ($null -eq $row) {
                            Write-Error "Failed to match address '$($hardware.Address)' of newly added hardware $($hardware.Name) to a row in the provided CSV. The hardware has been added, but the settings specified in the CSV have not been applied. Please consider reporting this on https://github.com/MilestoneSystemsInc/PowerShellSamples/issues"
                            return
                        }

                        # If user is assigning to non-default storage, then we need to discover the available storages
                        # on the recording server and cache the information so we don't query the same information repeatedly.
                        $recorder = $null
                        $storagePath = ''
                        if (-not [string]::IsNullOrWhiteSpace($row.StorageName)) {
                            if (-not $recorderByPath.ContainsKey($hardware.ParentItemPath)) {
                                $recorderByPath.($hardware.ParentItemPath) = Get-RecordingServer -Id ($hardware.ParentItemPath.Substring(16, 36))
                                $storageCache.($hardware.ParentItemPath) = @{}
                            }
                            $recorder = $recorderByPath.($hardware.ParentItemPath)
                            if (-not $storageCache.($hardware.ParentItemPath).ContainsKey($row.StorageName)) {
                                foreach ($storage in $recorder.StorageFolder.Storages) {
                                    $storageCache.($hardware.ParentItemPath).($storage.Name) = $storage.Path
                                }
                            }
                            $storagePath = $storageCache.($hardware.ParentItemPath).($row.StorageName)
                            if ([string]::IsNullOrWhiteSpace($storagePath)) {
                                $storagePath = [string]::Empty
                                Write-Warning "Storage named '$($row.StorageName)' not found on Recording Server '$($recorder.Name)'. All recording devices on $($hardware.Name) will record to the default storage."
                            }
                        }

                        if (-not [string]::IsNullOrWhiteSpace($row.HardwareName)) {
                            $hardware.Name = $row.HardwareName
                        }
                        if (-not [string]::IsNullOrWhiteSpace($row.Description) -and $row.Description -ne 'blank') {
                            $hardware.Description = $row.Description
                        }
                        $hardware.Save()

                        $enabledChannels = @{}
                        foreach ($deviceType in @('Camera', 'Microphone', 'Speaker', 'Input', 'Output', 'Metadata')) {
                            if ([string]::IsNullOrWhiteSpace($row."Enabled$($deviceType)Channels")) {
                                $enabledChannels[$deviceType] = @()
                            }
                            elseif ($row."Enabled$($deviceType)Channels" -eq 'All') {
                                $enabledChannels[$deviceType] = 0..511
                            }
                            else {
                                $enabledChannels[$deviceType] = @( $row."Enabled$($deviceType)Channels" -split ';' | Where-Object { ![string]::IsNullOrWhiteSpace($_) } | ForEach-Object { [int]($_.Trim())} )
                            }
                        }
                        if ($enabledChannels['Camera'].Count -eq 0) { $enabledChannels['Camera'] = @(0) }


                        $deviceNames = @{
                            Camera = @{}
                            Microphone = @{}
                            Speaker = @{}
                            Input = @{}
                            Output = @{}
                            Metadata = @{}
                        }
                        foreach ($key in $deviceNames.Keys) {
                            if (-not [string]::IsNullOrWhiteSpace($row."$($key)Name")) {
                                $names = @( $row."$($key)Name" -split ';' | ForEach-Object { $_.Trim() } )
                                for ($i = 0; $i -lt $names.Count; $i++) {
                                    $deviceNames[$key][$i] = $names[$i]
                                }
                            }
                        }

                        $gisPoint = 'POINT EMPTY'
                        if (-not [string]::IsNullOrWhiteSpace($row.Coordinates)) {
                            try {
                                $gisPoint = ConvertTo-GisPoint -Coordinates $row.Coordinates -ErrorAction Stop
                            } catch {
                                Write-Error $_
                                $gisPoint = 'POINT EMPTY'
                            }
                        }


                        $hardware | Get-VmsCamera -EnableFilter All  | Set-NewDeviceConfig -HardwareName $hardware.Name -EnabledChannels $enabledChannels.Camera     -ChannelNames $deviceNames.Camera     -DeviceGroups $row.CameraGroup     -DeviceGroupCache $deviceGroupCache -StoragePath $storagePath -GisPoint $gisPoint
                        $hardware | Get-Microphone | Set-NewDeviceConfig -HardwareName $hardware.Name -EnabledChannels $enabledChannels.Microphone -ChannelNames $deviceNames.Microphone -DeviceGroups $row.MicrophoneGroup -DeviceGroupCache $deviceGroupCache -StoragePath $storagePath -GisPoint $gisPoint
                        $hardware | Get-Speaker    | Set-NewDeviceConfig -HardwareName $hardware.Name -EnabledChannels $enabledChannels.Speaker    -ChannelNames $deviceNames.Speaker    -DeviceGroups $row.SpeakerGroup    -DeviceGroupCache $deviceGroupCache -StoragePath $storagePath -GisPoint $gisPoint
                        $hardware | Get-Input      | Set-NewDeviceConfig -HardwareName $hardware.Name -EnabledChannels $enabledChannels.Input      -ChannelNames $deviceNames.Input      -DeviceGroups $row.InputGroup      -DeviceGroupCache $deviceGroupCache -StoragePath $storagePath -GisPoint $gisPoint
                        $hardware | Get-Output     | Set-NewDeviceConfig -HardwareName $hardware.Name -EnabledChannels $enabledChannels.Output     -ChannelNames $deviceNames.Output     -DeviceGroups $row.OutputGroup     -DeviceGroupCache $deviceGroupCache -StoragePath $storagePath -GisPoint $gisPoint
                        $hardware | Get-Metadata   | Set-NewDeviceConfig -HardwareName $hardware.Name -EnabledChannels $enabledChannels.Metadata   -ChannelNames $deviceNames.Metadata   -DeviceGroups $row.MetadataGroup   -DeviceGroupCache $deviceGroupCache -StoragePath $storagePath -GisPoint $gisPoint
                        $hardware.ClearChildrenCache()
                        Write-Verbose "Completed configuration of $($hardware.Name) ($($hardware.Address)) in $($stopwatch.ElapsedMilliseconds)ms"
                        $hardware
                    }
                }
            }
        }
        finally {
            $ProgressPreference = $initialProgressPreference
        }
    }
}

function Set-NewDeviceConfig {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [object]
        $Device,

        [Parameter(Mandatory)]
        [string]
        $HardwareName,

        [Parameter()]
        [int[]]
        $EnabledChannels,

        [Parameter(Mandatory)]
        [hashtable]
        $ChannelNames,

        # Semi-colon delimited list of device group paths
        [Parameter()]
        [string]
        $DeviceGroups,

        [Parameter()]
        [hashtable]
        $DeviceGroupCache,

        # Specifies the Configuration API path like Storage[guid], not disk path.
        [Parameter()]
        [string]
        $StoragePath,

        [Parameter()]
        [string]
        $GisPoint = 'POINT EMPTY'
    )

    process {
        try {
            $deviceType = ''
            if ($Device.Path -match '(?<itemtype>.+)\[[a-fA-F0-9\-]{36}\](?:/(?<folderType>.+))?') {
                $deviceType = $Matches.itemtype
                if ($deviceType -eq 'InputEvent') {
                    $deviceType = 'Input'
                }
            }
            else {
                Write-Error "Failed to parse item type from configuration api path '$($Device.Path)'"
                return
            }

            if ([string]::IsNullOrWhiteSpace($ChannelNames[$Device.Channel])) {
                $Device.Name = $HardwareName + " - $deviceType $($Device.Channel + 1)"
            }
            else {
                $Device.Name = $ChannelNames[$Device.Channel]
            }

            if ($Device.Channel -in $EnabledChannels) {
                $Device.Enabled = $true
                if (-not [string]::IsNullOrWhiteSpace($DeviceGroups)) {
                    foreach ($groupName in @( $DeviceGroups -split ';' )) {
                        if (-not $DeviceGroupCache.$deviceType.ContainsKey($groupName)) {
                            $DeviceGroupCache.$deviceType.$groupName = New-VmsDeviceGroup -Type $deviceType -Path $groupName
                        }
                        $DeviceGroupCache.$deviceType.$groupName | Add-VmsDeviceGroupMember -Device $Device
                    }
                }
            }

            $device.GisPoint = $GisPoint
            $Device.Save()
        }
        catch [VideoOS.Platform.Proxy.ConfigApi.ValidateResultException] {
            foreach ($errorResult in $_.Exception.ValidateResult.ErrorResults) {
                Write-Error "Failed to update settings on $($Device.Name) ($($Device.Id)) due to a $($errorResult.ErrorProperty) validation error. $($errorResult.ErrorText.Trim('.'))."
            }
        }

        try {
            if (-not [string]::IsNullOrWhiteSpace($StoragePath) -and $null -ne $Device.RecordingStorage) {
                if ($Device.RecordingStorage -ne $StoragePath) {
                    $moveData = $false
                    $null = $Device.ChangeDeviceRecordingStorage($StoragePath, $moveData) | Wait-VmsTask -Cleanup
                }
            }
        }
        catch [VideoOS.Platform.ServerFaultMIPException] {
            $errorText = $_.Exception.InnerException.Message
            Write-Error "Failed to update recording storage for $($Device.Name) ($($Device.Id). $($errorText.Trim('.'))."
        }
    }
}

Register-ArgumentCompleter -CommandName Import-VmsHardware -ParameterName RecordingServer -ScriptBlock {
    $values = (Get-VmsRecordingServer).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Import-VmsLicense {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.LicenseInformation])]
    param (
        [Parameter(Mandatory)]
        [string]
        $Path
    )

    begin {
        Assert-VmsVersion -MinimumVersion 20.2 -ErrorAction Stop -Comment "Management of Milestone XProtect VMS licensing using MIP SDK was introduced in version 2020 R2 (v20.2). This function is not compatible with your current Management Server version."
    }

    process {
        try {
            $filePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
            if (-not (Test-Path $filePath)) {
                throw [System.IO.FileNotFoundException]::new('Import-VmsLicense could not find the file.', $filePath)
            }
            $bytes = [IO.File]::ReadAllBytes($filePath)
            $b64 = [Convert]::ToBase64String($bytes)
            $ms = Get-VmsManagementServer
            $result = $ms.LicenseInformationFolder.LicenseInformations[0].UpdateLicense($b64)
            if ($result.State -eq 'Success') {
                $ms.LicenseInformationFolder.ClearChildrenCache()
                Write-Output $ms.LicenseInformationFolder.LicenseInformations[0]
            }
            else {
                Write-Error "Failed to import updated license file. $($result.ErrorText.Trim('.'))."
            }
        }
        catch {
            Write-Error -Message $_.Message -Exception $_.Exception
        }
    }
}
function Import-VmsViewGroup {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.ViewGroup])]
    param(
        [Parameter(Mandatory)]
        [string]
        $Path,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $NewName,

        [Parameter()]
        [ValidateNotNull()]
        [VideoOS.Platform.ConfigurationItems.ViewGroup]
        $ParentViewGroup
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        [environment]::CurrentDirectory = Get-Location
        $Path = [io.path]::GetFullPath($Path)

        $source = [io.file]::ReadAllText($Path) | ConvertFrom-Json -ErrorAction Stop
        if ($source.ItemType -ne 'ViewGroup') {
            throw "Invalid file specified in Path parameter. File must be in JSON format and the root object must have an ItemType value of ViewGroup."
        }
        if ($MyInvocation.BoundParameters.ContainsKey('NewName')) {
            ($source.Properties | Where-Object Key -eq 'Name').Value = $NewName
        }
        $params = @{
            Source = $source
        }
        if ($MyInvocation.BoundParameters.ContainsKey('ParentViewGroup')) {
            $params.ParentViewGroup = $ParentViewGroup
        }
        Copy-ViewGroupFromJson @params
    }
}
function Invoke-VmsLicenseActivation {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.LicenseInformation])]
    param (
        [Parameter(Mandatory)]
        [pscredential]
        $Credential,

        [Parameter()]
        [switch]
        $EnableAutoActivation
    )

    begin {
        Assert-VmsVersion -MinimumVersion 20.2 -ErrorAction Stop -Comment "Management of Milestone XProtect VMS licensing using MIP SDK was introduced in version 2020 R2 (v20.2). This function is not compatible with your current Management Server version."
    }

    process {
        try {
            $result = $ms.LicenseInformationFolder.LicenseInformations[0].ActivateLicense($Credential.UserName, $Credential.Password, $EnableAutoActivation) | Wait-VmsTask -Title 'Performing online license activation' -Cleanup
            $state = ($result.Properties | Where-Object Key -eq 'State').Value
            if ($state -eq 'Success') {
                $ms.ClearChildrenCache()
                Write-Output $ms.LicenseInformationFolder.LicenseInformations[0]
            }
            else {
                $errorText = ($result.Properties | Where-Object Key -eq 'ErrorText').Value
                if ([string]::IsNullOrWhiteSpace($errorText)) {
                    $errorText = "Unknown error."
                }
                Write-Error "Call to ActivateLicense failed. $($errorText.Trim('.'))."
            }
        }
        catch {
            Write-Error -Message $_.Message -Exception $_.Exception
        }
    }
}
function Join-VmsDeviceGroupPath {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        # Specifies a device group path in unix directory form with forward-slashes as separators.
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
        [string[]]
        $PathParts
    )

    begin {
        $sb = [text.stringbuilder]::new()
    }

    process {

        foreach ($part in $PathParts) {
            $part | Foreach-Object {
                $null = $sb.Append('/{0}' -f ($_ -replace '(?<!`)/', '`/'))
            }
        }
    }

    end {
        $sb.ToString()
    }
}
function New-VmsDeviceGroup {
    [CmdletBinding()]
    [Alias('Add-DeviceGroup')]
    [OutputType([VideoOS.Platform.ConfigurationItems.IConfigurationItem])]
    param (
        [Parameter(ValueFromPipeline, Position = 0, ParameterSetName = 'ByName')]
        [ValidateScript({
            if ($_.GetType().Name -notmatch '^(Camera|Microphone|Speaker|InputEvent|Output|Metadata)Group$') {
                throw "Unexpected object type: $($_.GetType().FullName)"
            }
            $true
        })]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $ParentGroup,

        [Parameter(Mandatory, Position = 1, ParameterSetName = 'ByName')]
        [string[]]
        $Name,

        [Parameter(Mandatory, Position = 2, ParameterSetName = 'ByPath')]
        [string[]]
        $Path,

        [Parameter(Position = 3, ParameterSetName = 'ByName')]
        [Parameter(Position = 3, ParameterSetName = 'ByPath')]
        [string]
        $Description,

        [Parameter(Position = 4, ParameterSetName = 'ByName')]
        [Parameter(Position = 4, ParameterSetName = 'ByPath')]
        [Alias('DeviceCategory')]
        [ValidateSet('Camera', 'Microphone', 'Speaker', 'Input', 'Output', 'Metadata')]
        [string]
        $Type = 'Camera'
    )

    begin {
        $adjustedType = $Type
        if ($adjustedType -eq 'Input') {
            # Inputs on cameras have an object type called "InputEvent"
            # but we don't want the user to have to remember that. Besides,
            # inputs and events are two different things.
            $adjustedType = 'InputEvent'
        }
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'ByName' {
                $getGroupParams = @{
                    Type = $Type
                }
                $rootGroup = Get-VmsManagementServer
                if ($ParentGroup) {
                    $getGroupParams.ParentGroup = $ParentGroup
                    $rootGroup = $ParentGroup
                }
                foreach ($n in $Name) {
                    try {
                        $getGroupParams.Name = $n
                        $group = Get-VmsDeviceGroup @getGroupParams -ErrorAction SilentlyContinue
                        if ($null -eq $group) {
                            $serverTask = $rootGroup."$($adjustedType)GroupFolder".AddDeviceGroup($n, $Description)
                            $rootGroup."$($adjustedType)GroupFolder".ClearChildrenCache()
                            New-Object -TypeName "VideoOS.Platform.ConfigurationItems.$($adjustedType)Group" -ArgumentList $rootGroup.ServerId, $serverTask.Path
                        }
                        else {
                            $group
                        }
                    } catch {
                        Write-Error -ErrorRecord $_
                    }
                }
            }
            'ByPath' {
                $params = @{
                    Type = $Type
                }
                foreach ($p in $Path) {
                    $p | Split-VmsDeviceGroupPath | Foreach-Object {
                        $params.Name = $_
                        $group = Get-VmsDeviceGroup @params -ErrorAction SilentlyContinue
                        if ($null -eq $group) {
                            $group = New-VmsDeviceGroup @params -ErrorAction Stop
                        }
                        $params.ParentGroup = $group
                    }
                    if (-not [string]::IsNullOrWhiteSpace($Description)) {
                        $group.Description = $Description
                        $group.Save()
                    }
                    $group
                }
            }
            Default {}
        }
    }
}
function New-VmsRole {
    [CmdletBinding(SupportsShouldProcess)]
    [Alias('Add-Role')]
    [OutputType([VideoOS.Platform.ConfigurationItems.Role])]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [string]
        $Name,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Description,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $AllowSmartClientLogOn,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $AllowMobileClientLogOn,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $AllowWebClientLogOn,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $DualAuthorizationRequired,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $MakeUsersAnonymousDuringPTZSession,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('RoleClientLogOnTimeProfile')]
        [TimeProfileNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.TimeProfile]
        $ClientLogOnTimeProfile,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('RoleDefaultTimeProfile')]
        [TimeProfileNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.TimeProfile]
        $DefaultTimeProfile,

        [Parameter()]
        [switch]
        $PassThru
    )

    process {
        try {
            $ms = Get-VmsManagementServer -ErrorAction Stop
            if (-not $PSCmdlet.ShouldProcess("$($ms.Name) ($($ms.ServerId.Uri))", "Create role '$Name'")) {
                return
            }
            $result = $ms.RoleFolder.AddRole(
                $Name, $Description,
                $DualAuthorizationRequired,
                $MakeUsersAnonymousDuringPTZSession,
                $AllowMobileClientLogOn, $AllowSmartClientLogOn, $AllowWebClientLogOn,
                $DefaultTimeProfile.Path, $ClientLogOnTimeProfile.Path)
            if ($result.State -ne 'Success') {
                throw "RoleFolder.AddRole(..) state: $($result.State). Error: ($result.GetProperty('ErrorText'))"
            } elseif ($PassThru) {
                $ms.RoleFolder.Roles | Where-Object Path -eq $result.GetProperty('Path')
            }
        } catch {
            Write-Error -ErrorRecord $_
        }
    }
}

Register-ArgumentCompleter -CommandName New-VmsRole -ParameterName DefaultTimeProfile -ScriptBlock {
    $values = (Get-VmsManagementServer).TimeProfileFolder.TimeProfiles.Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName New-VmsRole -ParameterName ClientLogOnTimeProfile -ScriptBlock {
    $values = (Get-VmsManagementServer).TimeProfileFolder.TimeProfiles.Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function New-VmsView {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.ViewGroup]
        $ViewGroup,

        [Parameter(Mandatory, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter(Position = 2)]
        [VideoOS.Platform.ConfigurationItems.Camera[]]
        $Cameras,

        [Parameter(ParameterSetName = 'Default')]
        [string]
        $StreamName,

        [Parameter(ParameterSetName = 'Custom')]
        [ValidateRange(1, 100)]
        [int]
        $Columns,

        [Parameter(ParameterSetName = 'Custom')]
        [ValidateRange(1, 100)]
        [int]
        $Rows,

        [Parameter(ParameterSetName = 'Advanced')]
        [string]
        $LayoutDefinitionXml,

        [Parameter(ParameterSetName = 'Advanced')]
        [string[]]
        $ViewItemDefinitionXml
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        try {
            if ($null -eq $ViewGroup.ViewFolder) {
                throw "Top-level view groups cannot contain views. Views may only be added to child view groups."
            }
            switch ($PSCmdlet.ParameterSetName) {
                'Default' { $LayoutDefinitionXml = New-VmsViewLayout -ViewItemCount $Cameras.Count }
                'Custom'  { $LayoutDefinitionXml = New-VmsViewLayout -Columns $Columns -Rows $Rows }
            }

            $invokeInfo = $ViewGroup.ViewFolder.AddView($LayoutDefinitionXml)
            if ($invokeInfo.State -ne 'Success') {
                throw $invokeInfo.ErrorText
            }
            $invokeInfo.SetProperty('Name', $Name)
            $invokeResult = $invokeInfo.ExecuteDefault()
            if ($invokeResult.State -ne 'Success') {
                throw $invokeResult.ErrorText
            }
            $ViewGroup.ViewFolder.ClearChildrenCache()
            $view = $ViewGroup.ViewFolder.Views | Where-Object Path -eq $invokeResult.Path
            $dirty = $false

            if ($PSCmdlet.ParameterSetName -ne 'Advanced') {
                $smartClientId = GetSmartClientId -View $view
                $i = 0
                if ($Cameras.Count -gt $view.ViewItemChildItems.Count) {
                    Write-Warning "The view is not large enough for the number of cameras selected. Only the first $($view.ViewItemChildItems.Count) of $($Cameras.Count) cameras will be included."
                }
                foreach ($cam in $Cameras) {
                    $streamId = [guid]::Empty
                    if (-not [string]::IsNullOrWhiteSpace($StreamName)) {
                        $stream = $cam | Get-VmsCameraStream | Where-Object DisplayName -eq $StreamName | Select-Object -First 1

                        if ($null -ne $stream) {
                            $streamId = $stream.StreamReferenceId
                        } else {
                            Write-Warning "Stream named ""$StreamName"" not found on $($cam.Name). Default live stream will be used instead."
                        }
                    }
                    $properties = $cam | New-VmsViewItemProperties -SmartClientId $smartClientId
                    $properties.LiveStreamId = $streamId
                    $viewItemDefinition = $properties | New-CameraViewItemDefinition
                    $view.ViewItemChildItems[$i++].SetProperty('ViewItemDefinitionXml', $viewItemDefinition)
                    $dirty = $true
                    if ($i -ge $view.ViewItemChildItems.Count) {
                        break
                    }
                }
            } else {
                for ($i = 0; $i -lt $ViewItemDefinitionXml.Count; $i++) {
                    $view.ViewItemChildItems[$i].SetProperty('ViewItemDefinitionXml', $ViewItemDefinitionXml[$i])
                    $dirty = $true
                }
            }

            if ($dirty) {
                $view.Save()
            }
            Write-Output $view
        } catch {
            Write-Error $_
        }
    }
}

function GetSmartClientId ($View) {
    # There's a smartClientId value in the existing default ViewItemDefinitionXml
    # that I'm told we should reuse. I haven't noticed that it actually matters
    # what the ID is though, or how it's used, so we'll use a random one if for
    # some reason we fail to find an ID in the default viewitemdefinitionxml.
    $id = New-Guid
    if ($view.ViewItemChildItems[0].GetProperty('ViewItemDefinitionXml') -match 'smartClientId="(?<id>.{36})"') {
        $id = $Matches.id
    }
    Write-Output $id
}
function New-VmsViewGroup {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.ViewGroup])]
    param (
        [Parameter(Mandatory, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter(ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.ViewGroup]
        $Parent,

        [Parameter()]
        [string]
        $Description,

        [Parameter()]
        [switch]
        $Force
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        $vgFolder = (Get-VmsManagementServer).ViewGroupFolder
        if ($null -ne $Parent) {
            $vgFolder = $Parent.ViewGroupFolder
        }
        if ($Force) {
            $vg = $vgFolder.ViewGroups | Where-Object DisplayName -eq $Name
            if ($null -ne $vg) {
                Write-Output $vg
                return
            }
        }
        try {
            $result = $vgFolder.AddViewGroup($Name, $Description)
            if ($result.State -eq 'Success') {
                $vgFolder.ClearChildrenCache()
                Get-VmsViewGroup -Name $Name -Parent $Parent
            } else {
                Write-Error $result.ErrorText
            }
        } catch {
            if ($Force -and $_.Exception.Message -like '*Group name already exist*') {
                Get-VmsViewGroup -Name $Name
            } else {
                Write-Error $_
            }
        }
    }
}
function Remove-Hardware {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Hardware[]]
        $Hardware
    )

    begin {
        $recorders = @{}
        (Get-VmsManagementServer).RecordingServerFolder.RecordingServers | Foreach-Object {
            $recorders[$_.Path] = $_
        }
        $foldersNeedingCacheReset = @{}
    }

    process {
        try {
            $action = 'Permanently delete hardware and all associated video, audio and metadata from the VMS'
            foreach ($hw in $Hardware) {
                try {
                    $target = "$($hw.Name) with ID $($hw.Id)"
                    if ($PSCmdlet.ShouldProcess($target, $action)) {
                        $folder = $recorders[$hw.ParentItemPath].HardwareFolder
                        $result = $folder.DeleteHardware($hw.Path) | Wait-VmsTask -Title "Removing hardware $($hw.Name)" -Cleanup
                        $properties = @{}
                        $result.Properties | Foreach-Object { $properties[$_.Key] = $_.Value}
                        if ($properties.State -eq 'Success') {
                            $foldersNeedingCacheReset[$folder.Path] = $folder
                        } else {
                            Write-Error "An error occurred while deleting the hardware. $($properties.ErrorText.Trim('.'))."
                        }
                    }
                }
                catch [VideoOS.Platform.PathNotFoundMIPException] {
                    Write-Error "The hardware named $($hw.Name) with ID $($hw.Id) was not found."
                }
            }
        }
        catch [VideoOS.Platform.PathNotFoundMIPException] {
            Write-Error "One or more recording servers for the provided hardware values do not exist."
        }
    }

    end {
        $foldersNeedingCacheReset.Values | Foreach-Object {
            $_.ClearChildrenCache()
        }
    }
}
function Remove-VmsDeviceGroup {
    [CmdletBinding(ConfirmImpact = 'High', SupportsShouldProcess)]
    [Alias('Remove-DeviceGroup')]
    param (
        [Parameter(ValueFromPipeline)]
        [ValidateScript({
            if ($_.GetType().Name -notmatch '^(Camera|Microphone|Speaker|InputEvent|Output|Metadata)Group$') {
                throw "Unexpected object type: $($_.GetType().FullName)"
            }
            $true
        })]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem[]]
        $Group,

        [Parameter()]
        [switch]
        $Recurse
    )

    begin {
        $cacheToClear = @{}
    }

    process {
        foreach ($g in $Group) {
            $itemType = ($g | ConvertTo-ConfigItemPath).ItemType
            $target = "$itemType '$($g.Name)'"
            $action = "Delete"
            if ($Recurse) {
                $target += " and all group members"
            }
            if ($PSCmdlet.ShouldProcess($g.Name, $action)) {
                $parentFolder = Get-ConfigurationItem -Path $g.ParentPath
                $invokeInfo = $parentFolder | Invoke-Method -MethodId RemoveDeviceGroup
                ($invokeInfo.Properties | Where-Object Key -eq 'RemoveMembers').Value = $Recurse.ToString()
                ($invokeInfo.Properties | Where-Object Key -eq 'ItemSelection').Value = $g.Path
                $null = $invokeInfo | Invoke-Method -MethodId RemoveDeviceGroup
                $cacheToClear[$itemType] = $null
            }
        }

    }

    end {
        $cacheToClear.Keys | Foreach-Object {
            Write-Verbose "Clearing $_ cache"
            (Get-VmsManagementServer)."$($_)Folder".ClearChildrenCache()
        }
    }
}
function Remove-VmsDeviceGroupMember {
    [CmdletBinding(ConfirmImpact = 'High', SupportsShouldProcess)]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [ValidateScript({
            if ($_.GetType().Name -notmatch '^(Camera|Microphone|Speaker|InputEvent|Output|Metadata)Group$') {
                throw "Unexpected object type: $($_.GetType().FullName)"
            }
            $true
        })]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $Group,

        [Parameter(Mandatory, Position = 0, ParameterSetName = 'ByObject')]
        [ValidateScript({
            if ($_.GetType().Name -notmatch '^(Camera|Microphone|Speaker|InputEvent|Output|Metadata)$') {
                throw "Unexpected object type: $($_.GetType().FullName)"
            }
            $true
        })]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem[]]
        $Device,

        [Parameter(Mandatory, Position = 1, ParameterSetName = 'ById')]
        [guid[]]
        $DeviceId
    )

    process {
        $groupItemType = ($Group | ConvertTo-ConfigItemPath).ItemType -replace 'Group', ''
        $dirty = $false
        if ($Device) {
            $DeviceId = [guid[]]$Device.Id
            $map = @{}; $Device | Foreach-Object { $map[[guid]$_.Id] = $_ }
        }
        if ($PSCmdlet.ShouldProcess("$groupItemType group '$($Group.Name)'", "Remove $($DeviceId.Count) device group member(s)")) {
            foreach ($id in $DeviceId) {
                try {
                    $path = '{0}[{1}]' -f $groupItemType, $id
                    $null = $Group."$($groupItemType)Folder".RemoveDeviceGroupMember($path)
                    $dirty = $true
                } catch [VideoOS.Platform.ArgumentMIPException] {
                    Write-Error -Message "Failed to remove device group member: $_.Exception.Message" -Exception $_.Exception
                }
            }
        }
    }

    end {
        if ($dirty) {
            $Group."$($groupItemType)GroupFolder".ClearChildrenCache()
            (Get-VmsManagementServer)."$($groupItemType)GroupFolder".ClearChildrenCache()
        }
    }
}
function Remove-VmsFailoverGroup {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact="High")]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.FailoverGroup]
        $FailoverGroup
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.2 -ErrorAction Stop
    }

    process {
        if ($PSCmdlet.ShouldProcess($FailoverGroup.Name, "Remove failover group")) {
            $ms = Get-VmsManagementServer
            $task = $ms.FailoverGroupFolder.RemoveFailoverGroup($FailoverGroup.Path)
            if ($task.State -ne 'Success') {
                Write-Error "Remove-VmsFailoverGroup encounted an error. $($task.ErrorText.Trim('.'))."
            }
        }
    }
}
function Remove-VmsRole {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'ByName')]
    [Alias('Remove-Role')]
    param (
        [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName, ParameterSetName = 'ByName')]
        [Alias('RoleName', 'Name')]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role]
        $Role,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'ById')]
        [Alias('RoleId')]
        [guid]
        $Id
    )

    process {
        if ($null -eq $Role) {
            $Role = Get-VmsRole -Id $Id -ErrorAction Stop
        }
        if (-not $PSCmdlet.ShouldProcess("Role: $($Role.Name)", "Delete")) {
            return
        }
        try {
            $folder = (Get-VmsManagementServer).RoleFolder
            $invokeResult = $folder.RemoveRole($Role.Path)
            if ($invokeResult.State -ne 'Success') {
                throw "Error removing role '$($Role.Name)'. $($invokeResult.GetProperty('ErrorText'))"
            }
        }
        catch {
            Write-Error -ErrorRecord $_
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsRole -ParameterName Name -ScriptBlock {
    $values = (Get-VmsRole).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Get-VmsRole -ParameterName Id -ScriptBlock {
    $values = (Get-VmsRole | Sort-Object Name).Id
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Get-VmsRole -ParameterName RoleType -ScriptBlock {
    $values = (Get-VmsRole | Select-Object -First 1 | Select-Object -ExpandProperty RoleTypeValues).Values | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Remove-VmsRoleMember {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'ByUser')]
    [Alias('Remove-User')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0, ParameterSetName = 'ByUser')]
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0, ParameterSetName = 'BySid')]
        [Alias('RoleName')]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role[]]
        $Role,

        [Parameter(Mandatory, Position = 1, ParameterSetName = 'ByUser')]
        [VideoOS.Platform.ConfigurationItems.User[]]
        $User,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 2, ParameterSetName = 'BySid')]
        [string[]]
        $Sid
    )

    process {
        $removeRoleMember = {
            param($role, $member)
            if ($PSCmdlet.ShouldProcess("$($member.Domain)\$($member.AccountName)", "Remove member from role '$($role.Name)'")) {
                $null = $role.UserFolder.RemoveRoleMember($member.Path)
            }
        }
        foreach ($r in $Role) {
            switch ($PSCmdlet.ParameterSetName) {
                'ByUser' {
                    foreach ($u in $User) {
                        try {
                            $removeRoleMember.Invoke($r, $u)
                        }
                        catch {
                            Write-Error -ErrorRecord $_
                        }
                    }
                }

                'BySid' {
                    foreach ($u in $r | Get-VmsRoleMember | Where-Object Sid -in $Sid) {
                        try {
                            $removeRoleMember.Invoke($r, $u)
                        }
                        catch {
                            Write-Error -ErrorRecord $_
                        }
                    }
                }
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Remove-VmsRoleMember -ParameterName Role -ScriptBlock {
    $values = (Get-VmsRole).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Remove-VmsView {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.View])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.View[]]
        $View
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        foreach ($v in $View) {
            if ($PSCmdlet.ShouldProcess($($v.Name), "Remove view")) {
                $viewFolder = [VideoOS.Platform.ConfigurationItems.ViewFolder]::new($v.ServerId, $v.ParentPath)
                $result = $viewFolder.RemoveView($v.Path)
                if ($result.State -ne 'Success') {
                    Write-Error $result.ErrorText
                }
            }
        }
    }
}
function Remove-VmsViewGroup {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.ViewGroup[]]
        $ViewGroup,

        [Parameter()]
        [switch]
        $Recurse
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        foreach ($vg in $ViewGroup) {
            if ($PSCmdlet.ShouldProcess($vg.DisplayName, "Remove ViewGroup")) {
                try {
                    $viewGroupFolder = [VideoOS.Platform.ConfigurationItems.ViewGroupFolder]::new($vg.ServerId, $vg.ParentPath)
                    $result = $viewGroupFolder.RemoveViewGroup($Recurse, $vg.Path)
                    if ($result.State -eq 'Success') {
                        $viewGroupFolder.ClearChildrenCache()
                    } else {
                        Write-Error $result.ErrorText
                    }
                } catch {
                    Write-Error $_
                }
            }
        }
    }
}
function Resolve-VmsDeviceGroupPath {
    <#
    .SYNOPSIS
    Returns the logical path of the given device group in a "unix" directory format.
 
    .DESCRIPTION
    Milestone device groups are hierarchical with parent and child folders
    containing the parent and child groups. However, given a single device group
    there is no way to know the name of the parent, or how many parent groups
    there are before you reach the top of the hierarchy.
 
    This function will recursively retrieve the parent device groups and
    construct a path like "/root folder/subfolder/current group" making it easy
    to visualize the hierarchy in a flat text file or CSV.
 
    .PARAMETER Group
    A Camera, Microphone, Speaker, Input, Output, or Metadata group. Consider
    using Get-VmsDeviceGroup to select the desired group object.
 
    .EXAMPLE
    Get-VmsDeviceGroup -Type Camera -Recurse | Foreach-Object {
        [pscustomobject]@{
            Path = $_ | Resolve-VmsDeviceGroupPath
            Cameras = $_.CameraFolder.Cameras.Count
            Subgroups = ($_ | Get-VmsDeviceGroup).Count
        }
    }
 
    This example produces a table showing all camera groups with a path, a
    camera count, and a subfolder count for each camera group.
 
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [Alias('DeviceGroup')]
        [ValidateScript({
            if ($_.GetType().Name -notmatch '^(Camera|Microphone|Speaker|InputEvent|Output|Metadata)Group$') {
                throw "Unexpected object type: $($_.GetType().FullName)"
            }
            $true
        })]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $Group
    )

    process {
        $type = $Group | ConvertTo-ConfigItemPath | Select-Object -ExpandProperty ItemType
        $item = Get-ConfigurationItem -Path $Group.Path -ErrorAction Stop
        $path = ""
        while ($true) {
            $path = "/$($item.DisplayName -replace '(?<!`)/', '`/')" + $path
            if ($item.ParentPath -eq "/$($type)Folder") {
                break;
            }
            $item = Get-ConfigurationItem -Path $item.ParentPath.Replace("/$($type)Folder", "") -ErrorAction Stop
        }
        Write-Output $path
    }
}
function Set-VmsCamera {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([VideoOS.Platform.ConfigurationItems.Camera])]
    param(
        # Specifies a camera object such as is returned by Get-VmsCamera. You cannot pass a camera name here.
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Camera[]]
        $Camera,

        # Specifies a new camera name.
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Name,

        # Specifies a new camera name.
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $ShortName,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Description,

        # Specifies whether the camera is enabled in the VMS. To disable, use "-Enabled $false".
        [Parameter(ValueFromPipelineByPropertyName)]
        [bool]
        $Enabled,

        # Specifies new GPS coordinates in "latitude, longitude" format. This will automatically be converted to "POINT (X Y)" format used in the GisPoint property.
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Coordinates,

        # Specifies new GPS coordinates in GisPoint format "POINT (longitude latitude)".
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $GisPoint,

        # Specifies the direction the camera is facing as a value from 0 to 360 degrees where 0 is North, 90 is East, 180 is South, and 270 is West.
        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, 360)]
        [double]
        $Direction,

        # Specifies the direction the camera is facing as a value from 0 to 1. 0 is North, 0.25 is East, 0.5 is South, and 0.75 is West. If using a value from 0 to 360, you can divide the value by 360 to get a value in the correct range.
        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, 1)]
        [double]
        $CoverageDirection,

        # Specifies the field of view of the camera as a number between 0 and 360 degrees.
        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, 360)]
        [double]
        $FieldOfView,

        # Specifies the field of view of the camera as a number between 0 and 1, representing degrees from 0 to 360. For example, if the field of view is 54 degrees, you should set the value to (54 / 360) or 0.15.
        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, 1)]
        [double]
        $CoverageFieldOfView,

        # Specifies the depth or distance of the camera view. The unit of measure will either be "feet" or "meters" depending on the PC's region settings, and the unit can be overridden using the Units parameter. To specify meters or feet explicitly, include "-Units 'Metric'" for meters, or "-Units 'Imperial'" for feet.
        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, [double]::MaxValue)]
        [double]
        $Depth,

        # Specifies the depth or distance of the camera view in meters, exactly as it is stored in Milestone, with no consideration for the PC's region settings.
        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, [double]::MaxValue)]
        [double]
        $CoverageDepth,

        # Specifies whether the Depth value, if provided, should be interpreted as a metric unit (meters) or an imperial unit (feet). The default is set automatically based on the region setting of the PC.
        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateSet('Metric', 'Imperial')]
        [string]
        $Units,

        # Specifies whether the prebuffer is enabled for the camera(s). To disable, use "-PrebufferEnabled $false".
        [Parameter(ValueFromPipelineByPropertyName)]
        [bool]
        $PrebufferEnabled,

        # Specifies whether the prebuffer should be done in memory. When set to false, prebuffering will be done to disk. To prebuffer to disk, use "-PrebufferInMemory $false".
        [Parameter(ValueFromPipelineByPropertyName)]
        [bool]
        $PrebufferInMemory,

        # Specifies the size of the prebuffer in seconds. Note that the server will not accept a value greater than 15 seconds when prebuffering to memory, or a value greater than 10000 seconds when prebuffering to disk.
        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(1, 10000)]
        [int]
        $PrebufferSeconds,

        [Parameter(ValueFromPipelineByPropertyName)]
        [bool]
        $RecordingEnabled,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, 999)]
        [int]
        $RecordingFramerate,

        [Parameter(ValueFromPipelineByPropertyName)]
        [bool]
        $RecordKeyframesOnly,

        [Parameter(ValueFromPipelineByPropertyName)]
        [bool]
        $RecordOnRelatedDevices,

        [Parameter(ValueFromPipelineByPropertyName)]
        [bool]
        $EdgeStorageEnabled,

        [Parameter(ValueFromPipelineByPropertyName)]
        [bool]
        $EdgeStoragePlaybackEnabled,

        [Parameter(ValueFromPipelineByPropertyName)]
        [bool]
        $ManualRecordingTimeoutEnabled,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateRange(0, [int]::MaxValue)]
        [int]
        $ManualRecordingTimeoutMinutes,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        $standardProperties = @(
            'Name',
            'ShortName',
            'Description',
            'Enabled',
            'PrebufferEnabled',
            'PrebufferSeconds',
            'PrebufferInMemory',
            'GisPoint',
            'CoverageDirection',
            'CoverageFieldOfView',
            'CoverageDepth',
            'RecordingEnabled',
            'RecordingFramerate',
            'RecordKeyframesOnly',
            'RecordOnRelatedDevices',
            'EdgeStorageEnabled',
            'EdgeStoragePlaybackEnabled',
            'ManualRecordingTimeoutEnabled',
            'ManualRecordingTimeoutMinutes'
        )
        $IsMetric = [System.Globalization.RegionInfo]::CurrentRegion.IsMetric
        $ConversionFactor = 1
        $metersInAFoot = 0.3048000000012192
    }

    process {
        if (-not [string]::IsNullOrWhiteSpace($Units)) {
            $IsMetric = $Units -eq 'Metric'
        }
        if (-not $IsMetric) {
            $ConversionFactor = $metersInAFoot
        }

        # The $settings hashtable will be loaded with keys matching actual
        # Camera object properties to be changed, and their values.
        $settings = @{}
        foreach ($key in $standardProperties) {
            if ($MyInvocation.BoundParameters.ContainsKey($key)) {
                $settings[$key] = $MyInvocation.BoundParameters[$key]
            }
        }

        # The following section handles the special parameters that don't match
        # up directly to Camera property names. For example we accept a
        # Direction value in degrees from 0-360 because that is user-friendly,
        # and we map that value to the CoverageDirection property which takes a
        # value from 0 to 1.
        if ($MyInvocation.BoundParameters.ContainsKey('Coordinates')) {
            if ([string]::IsNullOrWhiteSpace($Coordinates)) {
                $settings['GisPoint'] = 'POINT EMPTY'
            } else {
                $settings['GisPoint'] = ConvertTo-GisPoint -Coordinates $Coordinates
            }
        }
        if ($MyInvocation.BoundParameters.ContainsKey('Direction')) {
            $settings['CoverageDirection'] = $Direction / 360
        }
        if ($MyInvocation.BoundParameters.ContainsKey('FieldOfView')) {
            $settings['CoverageFieldOfView'] = $FieldOfView / 360
        }
        if ($MyInvocation.BoundParameters.ContainsKey('Depth')) {
            $settings['CoverageDepth'] = $Depth * $ConversionFactor
        }

        # The $settings hashtable is now loaded with property names and values
        # so we will enumerate over the Cameras and if the new value is
        # different than the old value, we'll update it
        foreach ($cam in $Camera) {
            $dirty = $false
            if ($MyInvocation.BoundParameters.ContainsKey('WhatIf')) {
                # This enables us to perform validation against a different camera
                # object reference without changing properties on the original camera
                # object. Otherwise the -WhatIf parameter would still change the state
                # of the source camera object in local memory which could have side effects.
                $cam = [VideoOS.Platform.ConfigurationItems.Camera]::new((Get-Site).FQID.ServerId, $cam.Path)
            }
            foreach ($key in $settings.Keys) {
                if ($cam.$key -ne $settings[$key] -and $PSCmdlet.ShouldProcess($cam.Name, ('Changing {0} from {1} to {2}' -f $key, $cam.$key, $settings[$key]))) {
                    $dirty = $true
                    $cam.$key = $settings[$key]
                }
            }
            if ($cam.PrebufferSeconds -gt 15 -and $cam.PrebufferInMemory) {
                # The validation error for invalid PreBufferSeconds is not informative
                # So we'll handle that here and ensure the value can't be set to something
                # invalid.
                $message = 'PrebufferSeconds exceeds the maximum of value for in-memory buffering. The value will be updated to 15 seconds.'
                if ($script:Messages) {
                    $message = $script:Messages.PrebufferSecondsExceedsMaximumValue
                }
                Write-Warning $message
                $dirty = $true
                $cam.PrebufferSeconds = 15
            }

            try {
                if ($dirty -and $PSCmdlet.ShouldProcess($cam.Name, 'Saving changes')) {
                    # Only save changes and make the API call if we actually changed something.
                    $cam.Save()
                } else {
                    $validation = $cam.ValidateItem()
                    if (-not $validation.ValidatedOk) {
                        foreach ($errorResult in $validation.ErrorResults) {
                            $message = $errorResult.ErrorText
                            if ($script:Messages) {
                                $message = $script:Messages.ClientServiceValidateResult -f $errorResult.ErrorProperty, $cam.($errorResult.ErrorProperty), $errorResult.ErrorText
                            }
                            Write-Warning $message
                        }
                    }
                }
                if ($PassThru) {
                    Write-Output $cam
                }
            } catch {
                Write-Error -Exception $_.Exception -Message $_.Message -TargetObject $cam -Category InvalidOperation
            }
        }
    }
}
function Set-VmsCameraGeneralSetting {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Camera[]]
        $Camera,

        [Parameter(Mandatory, Position = 0)]
        [hashtable]
        $Settings
    )

    process {
        foreach ($cam in $Camera) {
            $target = $cam.Name
            $deviceDriverSettings = $cam.DeviceDriverSettingsFolder.DeviceDriverSettings[0]
            $generalSettings = $deviceDriverSettings.DeviceDriverSettingsChildItem
            if ($Settings.Keys.Count -gt 0) {
                $dirty = $false
                foreach ($key in $Settings.Keys) {
                    if ($key -notin $generalSettings.Properties.Keys) {
                        Write-Warning "A general setting named '$key' was not found on $($cam.Name)."
                    }

                    $currentValue = $generalSettings.Properties.GetValue($key)
                    if ($null -eq $currentValue -or $currentValue -eq $Settings.$key) {
                        continue
                    }

                    if ($PSCmdlet.ShouldProcess($target, "Changing $key from $currentValue to $($Settings.$key)")) {
                        $generalSettings.Properties.SetValue($key, $Settings.$key)
                        $dirty = $true
                    }
                }
                if ($dirty -and $PSCmdlet.ShouldProcess($target, "Save changes")) {
                    try {
                        $deviceDriverSettings.Save()
                    } catch [VideoOS.Platform.Proxy.ConfigApi.ValidateResultException] {
                        $mipException = $_.Exception -as [VideoOS.Platform.MIPException]
                        foreach ($errorResult in $mipException.ValidateResult.ErrorResults) {
                            $message = $errorResult.ErrorText
                            $null, $key, $null = $errorResult.ErrorProperty -split '/', 3
                            if ($script:Messages -and -not [string]::IsNullOrWhiteSpace($key)) {
                                $message = $script:Messages.ClientServiceValidateResult -f $key, $Settings.$key, $errorResult.ErrorText
                            }
                            Write-Error -Message $message -Exception $mipException
                        }
                        $cam.DeviceDriverSettingsFolder.ClearChildrenCache()
                    }
                }
            }
        }
    }
}
function Set-VmsCameraStream {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param (
        [Parameter(Mandatory, ParameterSetName = 'RemoveStream')]
        [switch]
        $Disabled,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'AddOrUpdateStream')]
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'RemoveStream')]
        [VmsCameraStreamConfig[]]
        $Stream,

        [Parameter(ParameterSetName = 'AddOrUpdateStream')]
        [string]
        $DisplayName,

        [Parameter(ParameterSetName = 'AddOrUpdateStream')]
        [switch]
        $Recorded,

        [Parameter(ParameterSetName = 'AddOrUpdateStream')]
        [switch]
        $LiveDefault,

        [Parameter(ParameterSetName = 'AddOrUpdateStream')]
        [ValidateSet('Always', 'Never', 'WhenNeeded', '', $null)]
        [string]
        $LiveMode,

        [Parameter(ParameterSetName = 'AddOrUpdateStream')]
        [hashtable]
        $Settings
    )

    begin {
        $updatedItems = [system.collections.generic.list[pscustomobject]]::new()
        $itemCache = @{}
    }

    process {
        foreach ($s in $Stream) {
            $target = "$($s.Name) on $($s.Camera.Name)"
            $deviceDriverSettings = $s.Camera.DeviceDriverSettingsFolder.DeviceDriverSettings[0]
            if ($itemCache.ContainsKey($deviceDriverSettings.Path)) {
                $deviceDriverSettings = $itemCache[$deviceDriverSettings.Path]
            } else {
                $itemCache[$deviceDriverSettings.Path] = $deviceDriverSettings
            }
            $streamUsages = $s.Camera.StreamFolder.Streams | Select-Object -First 1
            if ($null -ne $streamUsages -and $itemCache.ContainsKey($streamUsages.Path)) {
                $streamUsages = $itemCache[$streamUsages.Path]
            } elseif ($null -ne $streamUsages) {
                $itemCache[$streamUsages.Path] = $streamUsages
            }

            $streamRefToName = @{}
            if ($streamUsages.StreamUsageChildItems.Count -gt 0) {
                $streamNameToRef = $streamUsages.StreamUsageChildItems[0].StreamReferenceIdValues
                foreach ($key in $streamNameToRef.Keys) {
                    $streamRefToName[$streamNameToRef.$key] = $key
                }
                $streamUsageChildItem = $streamUsages.StreamUsageChildItems | Where-Object StreamReferenceId -eq $streamNameToRef[$Stream.Name]
            }

            if ($PSCmdlet.ParameterSetName -eq 'RemoveStream' -and $s.Enabled -and $PSCmdlet.ShouldProcess($s.Camera.Name, "Disabling stream '$($s.Name)'")) {
                if ($streamUsageChildItem.Record -or $streamUsageChildItem.LiveDefault) {
                    Write-Error "Stream $($s.Name) cannot be removed while it is either the LiveDefault or Record stream."
                } else {
                    $result = $streamUsages.RemoveStream($streamUsageChildItem.StreamReferenceId)
                    if ($result.State -eq 'Success') {
                        $s.Camera.StreamFolder.ClearChildrenCache()
                    } else {
                        Write-Error $result.ErrorText
                    }
                }
            } elseif ($PSCmdlet.ParameterSetName -eq 'AddOrUpdateStream') {
                $dirtyStreamUsages = $false
                if ($null -eq $streamUsageChildItem -and ($DisplayName -or $Recorded -or $LiveDefault -or $LiveMode) -and $PSCmdlet.ShouldProcess($s.Camera.Name, 'Adding a new stream usage')) {
                    try {
                        $result = $streamUsages.AddStream()
                        if ($result.State -ne 'Success') {
                            throw $result.ErrorText
                        }
                        $s.Camera.StreamFolder.ClearChildrenCache()
                        $streamUsages = $s.Camera.StreamFolder.Streams[0]
                        $streamUsageChildItem = $streamUsages.StreamUsageChildItems | Where-Object StreamReferenceId -eq $result.GetProperty('StreamReferenceId')
                        $streamUsageChildItem.StreamReferenceId = $streamNameToRef[$s.Name]
                        $streamUsageChildItem.Name = $s.Name
                        $dirtyStreamUsages = $true
                    } catch {
                        Write-Error $_
                    }
                }

                if ($MyInvocation.BoundParameters.ContainsKey('DisplayName') -and $DisplayName -ne $streamUsageChildItem.Name) {
                    if ($PSCmdlet.ShouldProcess($s.Camera.Name, "Setting DisplayName on $($streamUsageChildItem.Name)")) {
                        $streamUsageChildItem.Name = $DisplayName
                    }
                    $dirtyStreamUsages = $true
                }

                if ($MyInvocation.BoundParameters.ContainsKey('Recorded') -and $Recorded -and $Recorded -ne $streamUsageChildItem.Record) {
                    # Find and disable recording on the current recorded stream
                    $recordedStream = $streamUsages.StreamUsageChildItems | Where-Object Record
                    if ($PSCmdlet.ShouldProcess($s.Camera.Name, "Disabling recording on $($recordedStream.Name)")) {
                        $recordedStream.Record = $false
                        if ($recordedStream.LiveMode -eq 'Never' -and $PSCmdlet.ShouldProcess($s.Camera.Name, "Changing LiveMode from Never to WhenNeeded on $($recordedStream.Name)")) {
                            # This avoids a validation exception error.
                            $recordedStream.LiveMode = 'WhenNeeded'
                        }
                    }


                    # Turn recording on the new stream
                    if ($PSCmdlet.ShouldProcess($s.Camera.Name, "Enabling recording on $($streamUsageChildItem.Name)")) {
                        $streamUsageChildItem.Record = $true
                        $dirtyStreamUsages = $true
                    }
                }

                if ($MyInvocation.BoundParameters.ContainsKey('LiveDefault') -and $LiveDefault -and $LiveDefault -ne $streamUsageChildItem.LiveDefault) {
                    # Find and disable recording on the current recorded stream
                    $liveStream = $streamUsages.StreamUsageChildItems | Where-Object LiveDefault
                    if ($PSCmdlet.ShouldProcess($s.Camera.Name, "Disabling LiveDefault on $($liveStream.Name)")) {
                        $liveStream.LiveDefault = $false
                    }

                    # Turn recording on the new stream
                    if ($PSCmdlet.ShouldProcess($s.Camera.Name, "Enabling LiveDefault on $($streamUsageChildItem.Name)")) {
                        $streamUsageChildItem.LiveDefault = $true
                        $dirtyStreamUsages = $true
                    }
                }

                if ($MyInvocation.BoundParameters.ContainsKey('LiveMode') -and $LiveMode -ne $streamUsageChildItem.LiveMode  -and -not [string]::IsNullOrWhiteSpace($LiveMode)) {
                    if ($LiveMode -eq 'Never' -and (-not $streamUsageChildItem.Record -or $streamUsageChildItem.LiveDefault)) {
                        Write-Warning 'The LiveMode property can only be set to "Never" the recorded stream, and only when that stream is not used as the LiveDefault stream.'
                    } elseif ($PSCmdlet.ShouldProcess($s.Camera.Name, "Setting LiveMode on $($streamUsageChildItem.Name)")) {
                        $streamUsageChildItem.LiveMode = $LiveMode
                        $dirtyStreamUsages = $true
                    }
                }

                if ($dirtyStreamUsages -and $PSCmdlet.ShouldProcess($s.Camera.Name, "Saving StreamUsages")) {
                    $updatedItems.Add(
                        [pscustomobject]@{
                            Item = $streamUsages
                            Parent = $s.Camera
                        }
                    )
                }

                $streamChildItem = $deviceDriverSettings.StreamChildItems.Where( { $_.DisplayName -eq $s.Name })
                if ($Settings.Keys.Count -gt 0) {
                    $dirty = $false
                    foreach ($key in $Settings.Keys) {
                        if ($key -notin $s.Settings.Keys) {
                            Write-Warning "A setting with the key '$key' was not found for stream $($streamChildItem.DisplayName) on $($s.Camera.Name)."
                            continue
                        }

                        $currentValue = $streamChildItem.Properties.GetValue($key)
                        if ($currentValue -eq $Settings.$key) {
                            continue
                        }

                        if ($PSCmdlet.ShouldProcess($target, "Changing $key from $currentValue to $($Settings.$key)")) {
                            $streamChildItem.Properties.SetValue($key, $Settings.$key)
                            $dirty = $true
                        }
                    }
                    if ($dirty -and $PSCmdlet.ShouldProcess($target, "Save changes")) {
                        $updatedItems.Add(
                            [pscustomobject]@{
                                Item = $deviceDriverSettings
                                Parent = $s.Camera
                            }
                        )
                    }
                }
            }
        }
    }

    end {
        foreach ($update in $updatedItems) {
            try {
                $item = $itemCache[$update.Item.Path]
                if ($null -ne $item) {
                    $item.Save()
                }
            } catch [VideoOS.Platform.Proxy.ConfigApi.ValidateResultException] {
                $mipException = $_.Exception -as [VideoOS.Platform.MIPException]
                foreach ($errorResult in $mipException.ValidateResult.ErrorResults) {
                    $message = 'Validation error on {0}: {1}' -f $errorResult.ErrorProperty, $errorResult.ErrorText
                    Write-Error -Message $message -Exception $mipException -TargetObject $item
                }
                $update.Parent.ClearChildrenCache()
            } finally {
                if ($null -ne $item) {
                    $itemCache.Remove($item.Path)
                    $item = $null
                }
            }
        }
    }
}
function Set-VmsConnectionString {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param (
        [Parameter(Mandatory, Position = 0)]
        [string]
        $Component,

        [Parameter(Mandatory, Position = 1)]
        [string]
        $ConnectionString,

        [Parameter()]
        [switch]
        $Force
    )

    process {
        if ($null -eq (Get-Item -Path HKLM:\SOFTWARE\VideoOS\Server\ConnectionString -ErrorAction Ignore)) {
            Write-Error "Could not find the registry key 'HKLM:\SOFTWARE\VideoOS\Server\ConnectionString'. This key was introduced in 2022 R3, and this cmdlet is only compatible with VMS versions 2022 R3 and later."
            return
        }

        $currentValue = Get-VmsConnectionString -Component $Component -ErrorAction SilentlyContinue
        if ($null -eq $currentValue) {
            if ($Force) {
                if ($PSCmdlet.ShouldProcess((hostname), "Create new connection string value for $Component")) {
                    $null = New-ItemProperty -Path HKLM:\SOFTWARE\VideoOS\Server\ConnectionString -Name $Component -Value $ConnectionString
                }
            } else {
                Write-Error "A connection string for $Component does not exist. Retry with the -Force switch to create one anyway."
            }
        } else {
            if ($PSCmdlet.ShouldProcess((hostname), "Change connection string value of $Component")) {
                Set-ItemProperty -Path HKLM:\SOFTWARE\VideoOS\Server\ConnectionString -Name $Component -Value $ConnectionString
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Set-VmsConnectionString -ParameterName Component -ScriptBlock {
    $values = Get-Item HKLM:\SOFTWARE\videoos\Server\ConnectionString\ -ErrorAction Ignore | Select-Object -ExpandProperty Property
    if ($values) {
        Complete-SimpleArgument $args $values
    }
}
function Set-VmsDeviceGroup {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [ValidateScript({
                if ($_.GetType().Name -notmatch '^(Camera|Microphone|Speaker|InputEvent|Output|Metadata)Group$') {
                    throw "Unexpected object type: $($_.GetType().FullName)"
                }
                $true
            })]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $Group,

        [Parameter()]
        [string]
        $Name,

        [Parameter()]
        [string]
        $Description,

        [Parameter()]
        [switch]
        $PassThru
    )

    process {
        $groupType = ($Group | ConvertTo-ConfigItemPath).ItemType
        $dirty = $false
        $keys = $MyInvocation.BoundParameters.Keys | Where-Object { $_ -in @('Name', 'Description') }
        if ($PSCmdlet.ShouldProcess("$groupType '$($Group.Name)", "Update $([string]::Join(', ', $keys))")) {
            foreach ($key in $keys) {
                if ($Group.$key -cne $MyInvocation.BoundParameters[$key]) {
                    $Group.$key = $MyInvocation.BoundParameters[$key]
                    $dirty = $true
                }
            }
            if ($dirty) {
                Write-Verbose "Saving changes to $groupType '$($Group.Name)'"
                $Group.Save()
            } else {
                Write-Verbose "No changes made to $groupType '$($Group.Name)'"
            }
        }
        if ($PassThru) {
            $Group
        }
    }
}
function Set-VmsLicense {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.LicenseInformation])]
    param (
        [Parameter(Mandatory)]
        [string]
        $Path
    )

    begin {
        Assert-VmsVersion -MinimumVersion 20.2 -ErrorAction Stop -Comment "Management of Milestone XProtect VMS licensing using MIP SDK was introduced in version 2020 R2 (v20.2). This function is not compatible with your current Management Server version."
    }

    process {
        try {
            $filePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
            if (-not (Test-Path $filePath)) {
                throw [System.IO.FileNotFoundException]::new('Set-VmsLicense could not find the file.', $filePath)
            }
            $bytes = [IO.File]::ReadAllBytes($filePath)
            $b64 = [Convert]::ToBase64String($bytes)
            $result = $ms.LicenseInformationFolder.LicenseInformations[0].ChangeLicense($b64)
            if ($result.State -eq 'Success') {
                $oldSlc = $ms.LicenseInformationFolder.LicenseInformations[0].Slc
                $ms.ClearChildrenCache()
                $newSlc = $ms.LicenseInformationFolder.LicenseInformations[0].Slc
                if ($oldSlc -eq $newSlc) {
                    Write-Verbose "The software license code in the license file passed to Set-VmsLicense is the same as the existing software license code."
                }
                else {
                    Write-Verbose "Set-VmsLicense changed the software license code from $oldSlc to $newSlc."
                }
                Write-Output $ms.LicenseInformationFolder.LicenseInformations[0]
            }
            else {
                Write-Error "Call to ChangeLicense failed. $($result.ErrorText.Trim('.'))."
            }
        }
        catch {
            Write-Error -Message $_.Message -Exception $_.Exception
        }
    }
}
function Set-VmsRecordingServer {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
        [Alias('Recorder')]
        [RecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.RecordingServer[]]
        $RecordingServer,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Name,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Description,

        [Parameter(ValueFromPipelineByPropertyName)]
        [BooleanTransformAttribute()]
        [bool]
        $PublicAccessEnabled,

        [Parameter()]
        [ValidateRange(0, 65535)]
        [int]
        $PublicWebserverPort,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $PublicWebserverHostName,

        [Parameter(ValueFromPipelineByPropertyName)]
        [BooleanTransformAttribute()]
        [bool]
        $ShutdownOnStorageFailure,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $MulticastServerAddress,

        [Parameter()]
        [switch]
        $PassThru
    )

    process {
        foreach ($rec in $RecordingServer) {
            foreach ($property in $rec | Get-Member -MemberType Property | Where-Object Definition -like '*set;*' | Select-Object -ExpandProperty Name) {
                $parameterName = $property
                if (-not $PSBoundParameters.ContainsKey($parameterName)) {
                    continue
                }
                $newValue = $PSBoundParameters[$parameterName]
                if ($newValue -ceq $rec.$property) {
                    continue
                }
                if ($PSCmdlet.ShouldProcess($rec.Name, "Set $property to $newValue")) {
                    $rec.$property = $newValue
                    $dirty = $true
                }
            }

            if ($dirty) {
                $rec.Save()
            }
            if ($PassThru) {
                $rec
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Set-VmsRecordingServer -ParameterName RecordingServer -ScriptBlock {
    $values = (Get-VmsRecordingServer).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Set-VmsRole {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.Role])]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role[]]
        $Role,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Name,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Description,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $AllowSmartClientLogOn,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $AllowMobileClientLogOn,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $AllowWebClientLogOn,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $DualAuthorizationRequired,

        [Parameter(ValueFromPipelineByPropertyName)]
        [switch]
        $MakeUsersAnonymousDuringPTZSession,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('RoleClientLogOnTimeProfile')]
        [TimeProfileNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.TimeProfile]
        $ClientLogOnTimeProfile,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('RoleDefaultTimeProfile')]
        [TimeProfileNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.TimeProfile]
        $DefaultTimeProfile,

        [Parameter()]
        [switch]
        $PassThru
    )

    process {
        $dirty = $false
        foreach ($r in $Role) {
            foreach ($property in $r | Get-Member -MemberType Property | Where-Object Definition -like '*set;*' | Select-Object -ExpandProperty Name) {
                $parameterName = $property
                switch ($property) {
                    # We would just use the $property variable, but these properties are prefixed with "Role" which is
                    # redundant and doesn't match the New-VmsRole function.
                    'RoleClientLogOnTimeProfile' { $parameterName = 'ClientLogOnTimeProfile' }
                    'RoleDefaultTimeProfile'     { $parameterName = 'DefaultTimeProfile' }
                }
                if (-not $PSBoundParameters.ContainsKey($parameterName)) {
                    continue
                }

                $newValue = $PSBoundParameters[$parameterName]
                if ($parameterName -like '*Profile') {
                    $newValue = $newValue.Path
                }
                if ($PSBoundParameters[$parameterName] -ceq $r.$property) {
                    continue
                }
                if ($PSCmdlet.ShouldProcess($r.Name, "Set $property to $($PSBoundParameters[$parameterName])")) {
                    $r.$property = $newValue
                    $dirty = $true
                }
            }

            if ($dirty) {
                $r.Save()
            }
            if ($PassThru) {
                $r
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Set-VmsRole -ParameterName Role -ScriptBlock {
    $values = (Get-VmsRole).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Set-VmsRole -ParameterName DefaultTimeProfile -ScriptBlock {
    $values = (Get-VmsManagementServer).TimeProfileFolder.TimeProfiles.Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Set-VmsRole -ParameterName ClientLogOnTimeProfile -ScriptBlock {
    $values = (Get-VmsManagementServer).TimeProfileFolder.TimeProfiles.Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Set-VmsRoleOverallSecurity {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([hashtable])]
    param (
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('RoleName')]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role]
        $Role,

        [Parameter(ValueFromPipelineByPropertyName)]
        [SecurityNamespaceTransformAttribute()]
        [string]
        $SecurityNamespace,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [hashtable]
        $Permissions
    )

    process {
        if ($null -eq $Role) {
            $Role = Get-VmsRole | Where-Object Path -eq $Permissions.Role
            if ($null -eq $Role) {
                Write-Error "Role must be provided either using the Role parameter, or by including a key of 'Role' in the Permissions hashtable with the Configuration Item path of an existing role."
                return
            }
        }

        if ([string]::IsNullOrWhiteSpace($SecurityNamespace)) {
            $SecurityNamespace = $Permissions.SecurityNamespace
            if ([string]::IsNullOrWhiteSpace($SecurityNamespace)) {
                Write-Error "SecurityNamespace must be provided either using the SecurityNamespace parameter, or by including a key of 'SecurityNamespace' in the Permissions hashtable with a GUID value matching the ID of an existing overall security namespace."
                return
            }
        }

        if ($Role.RoleType -notin 'UserDefined', 'Role') {
            Write-Error "Overall security settings do not apply to the Administrator role."
            return
        }

        try {
            $invokeInfo = $Role.ChangeOverallSecurityPermissions($SecurityNamespace)
            $attributes = @{}
            $invokeInfo.GetPropertyKeys() | Foreach-Object { $attributes[$_] = $invokeInfo.GetProperty($_) }
            $dirty = $false
            foreach ($key in $Permissions.Keys) {
                if ($key -in 'DisplayName', 'SecurityNamespace', 'Role') {
                    continue
                }
                if (-not $attributes.ContainsKey($key)) {
                    Write-Warning "Attribute '$key' not found in SecurityNamespace"
                    continue
                }
                elseif ($attributes[$key] -cne $Permissions[$key]) {
                    if ($PSCmdlet.ShouldProcess($Role.Name, "Set $key to $($Permissions[$key])")) {
                        $invokeInfo.SetProperty($key, $Permissions[$key])
                        $dirty = $true
                    }
                }
            }
            if ($dirty) {
                $null = $invokeInfo.ExecuteDefault()
            }
        }
        catch {
            Write-Error -ErrorRecord $_
        }
    }
}


Register-ArgumentCompleter -CommandName Set-VmsRoleOverallSecurity -ParameterName Role -ScriptBlock {
    $values = (Get-VmsRole).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Set-VmsRoleOverallSecurity -ParameterName SecurityNamespace -ScriptBlock {
    $values = (Get-VmsRole -RoleType UserDefined).ChangeOverallSecurityPermissions().SecurityNamespaceValues.Keys | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Set-VmsSiteInfo {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateScript({ ValidateSiteInfoTagName @args })]
        [string]
        $Property,

        [Parameter(Mandatory, Position = 1, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateLength(1, 256)]
        [string]
        $Value,

        [Parameter()]
        [switch]
        $Append
    )

    begin {
        Assert-VmsVersion -MinimumVersion 20.2 -Comment 'Support reading and modifying Site Information was introduced in XProtect 2020 R2.' -ErrorAction Stop
    }

    process {
        $ownerPath = 'BasicOwnerInformation[{0}]' -f (Get-VmsManagementServer).Id
        $ownerInfo = Get-ConfigurationItem -Path $ownerPath

        $existingProperties = $ownerInfo.Properties.Key | Foreach-Object { $_ -split '/' | Select-Object -Last 1 }
        if ($Property -in $existingProperties -and -not $Append) {
            # Update existing entry instead of adding a new one
            if ($PSCmdlet.ShouldProcess((Get-Site).Name, "Change $Property entry value to '$Value' in site information")) {
                $p = $ownerInfo.Properties | Where-Object { $_.Key.EndsWith($Property) }
                if ($p.Count -gt 1) {
                    Write-Warning "Site information has multiple values for $Property. Only the first value can be updated with this command."
                    $p = $p[0]
                }
                $p.Value = $Value
                $invokeResult = $ownerInfo | Set-ConfigurationItem
                if (($invokeResult.Properties | Where-Object Key -eq 'State').Value -ne 'Success') {
                    Write-Error "Failed to update Site Information: $($invokeResult.Properties | Where-Object Key -eq 'ErrorText')"
                }
            }
        } elseif ($PSCmdlet.ShouldProcess((Get-Site).Name, "Add $Property entry with value '$Value' to site information")) {
            # Add new, or additional entry for the given property value
            $invokeInfo = $ownerInfo | Invoke-Method -MethodId AddBasicOwnerInfo
            foreach ($p in $invokeInfo.Properties) {
                switch ($p.Key) {
                    'TagType' { $p.Value = $Property }
                    'TagValue' { $p.Value = $Value }
                }
            }
            $invokeResult = $invokeInfo | Invoke-Method -MethodId AddBasicOwnerInfo
            if (($invokeResult.Properties | Where-Object Key -eq 'State').Value -ne 'Success') {
                Write-Error "Failed to update Site Information: $($invokeResult.Properties | Where-Object Key -eq 'ErrorText')"
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Set-VmsSiteInfo -ParameterName Property -ScriptBlock { OwnerInfoPropertyCompleter @args }
function Set-VmsView {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.View])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.View]
        $View,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter()]
        [string]
        $Description,

        [Parameter()]
        [Nullable[int]]
        $Shortcut,

        [Parameter()]
        [string[]]
        $ViewItemDefinition,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        $dirty = $false
        foreach ($key in 'Name', 'Description', 'Shortcut') {
            if ($MyInvocation.BoundParameters.ContainsKey($key)) {
                $value = $MyInvocation.BoundParameters[$key]
                if ($View.$key -ceq $value) { continue }
                if ($PSCmdlet.ShouldProcess($View.DisplayName, "Changing $key from $($View.$key) to $value")) {
                    $View.$key = $value
                    $dirty = $true
                }
            }
        }

        if ($MyInvocation.BoundParameters.ContainsKey('ViewItemDefinition')) {
            for ($i = 0; $i -lt $ViewItemDefinition.Count; $i++) {
                $definition = $ViewItemDefinition[$i]
                if ($PSCmdlet.ShouldProcess($View.DisplayName, "Update ViewItem $($i + 1)")) {
                    $View.ViewItemChildItems[$i].ViewItemDefinitionXml = $definition
                    $dirty = $true
                }
            }
        }

        if ($dirty -and $PSCmdlet.ShouldProcess($View.DisplayName, 'Saving changes')) {
            $View.Save()
        }

        if ($PassThru) {
            Write-Output $View
        }
    }
}
function Set-VmsViewGroup {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.ViewGroup])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.ViewGroup]
        $ViewGroup,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter()]
        [string]
        $Description,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        foreach ($key in 'Name', 'Description') {
            if ($MyInvocation.BoundParameters.ContainsKey($key)) {
                $value = $MyInvocation.BoundParameters[$key]
                if ($ViewGroup.$key -ceq $value) { continue }
                if ($PSCmdlet.ShouldProcess($ViewGroup.DisplayName, "Changing $key from $($ViewGroup.$key) to $value")) {
                    $ViewGroup.$key = $value
                    $ViewGroup.Save()
                }
            }
        }
        if ($PassThru) {
            Write-Output $ViewGroup
        }
    }
}
function Set-VmsViewGroupAcl {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VmsViewGroupAcl[]]
        $ViewGroupAcl
    )

    begin {
        Assert-VmsVersion -MinimumVersion 21.1 -Comment 'Support for views and view groups in Configuration API was introduced in XProtect 2021 R1.' -ErrorAction Stop
    }

    process {
        foreach ($acl in $ViewGroupAcl) {
            $path = [VideoOS.Platform.Proxy.ConfigApi.ConfigurationItemPath]::new($acl.Path)
            $viewGroup = Get-VmsViewGroup -Id $path.Id
            $target = "View group ""$($viewGroup.DisplayName)"""
            if ($PSCmdlet.ShouldProcess($target, "Updating security permissions for role $($acl.Role.Name)")) {
                $invokeInfo = $viewGroup.ChangeSecurityPermissions($acl.Role.Path)
                $dirty = $false
                foreach ($key in $acl.SecurityAttributes.Keys) {
                    $newValue = $acl.SecurityAttributes[$key]
                    $currentValue = $invokeInfo.GetProperty($key)
                    if ($newValue -cne $currentValue -and $PSCmdlet.ShouldProcess($target, "Changing $key from $currentValue to $newValue")) {
                        $invokeInfo.SetProperty($key, $newValue)
                        $dirty = $true
                    }

                }
                if ($dirty -and $PSCmdlet.ShouldProcess($target, "Saving security permission changes for role $($acl.Role.Name)")) {
                    $invokeResult = $invokeInfo.ExecuteDefault()
                    if ($invokeResult.State -ne 'Success') {
                        Write-Error $invokeResult.ErrorText
                    }
                }
            }
        }
    }
}
function Split-VmsDeviceGroupPath {
    [CmdletBinding()]
    [OutputType([string[]])]
    param (
        # Specifies a device group path in unix directory form with forward-slashes as separators.
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ValueFromPipeline, Position = 0)]
        [string]
        $Path
    )

    process {
        <#
        Path separator = /
        Escape character = `
        Steps:
            1. Remove unescaped leading and trailing path separator characters
            2. Split path string on unescaped path separators
            3. In each path part, replace the `/ character sequence with /
        #>

        $Path.TrimStart('/') -replace '(?<!`)/$', '' -split '(?<!`)/' | Foreach-Object { $_ -replace '`/', '/' } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
    }
}
function Start-VmsHardwareScan {
    [CmdletBinding()]
    [OutputType([VmsHardwareScanResult])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [RecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.RecordingServer[]]
        $RecordingServer,

        [Parameter(Mandatory, ParameterSetName = 'Express')]
        [switch]
        $Express,

        [Parameter(ParameterSetName = 'Manual')]
        [uri[]]
        $Address = @(),

        [Parameter(ParameterSetName = 'Manual')]
        [ipaddress]
        $Start,

        [Parameter(ParameterSetName = 'Manual')]
        [ipaddress]
        $End,

        [Parameter(ParameterSetName = 'Manual')]
        [string]
        $Cidr,

        [Parameter(ParameterSetName = 'Manual')]
        [int]
        $HttpPort = 80,

        [Parameter(ParameterSetName = 'Manual')]
        [int[]]
        $DriverNumber = @(),

        [Parameter(ParameterSetName = 'Manual')]
        [string[]]
        $DriverFamily,

        [Parameter()]
        [pscredential[]]
        $Credential,

        [Parameter()]
        [switch]
        $UseDefaultCredentials,

        [Parameter()]
        [switch]
        $UseHttps,

        [Parameter()]
        [switch]
        $PassThru
    )

    process {
        $tasks = @()
        $recorderPathMap = @{}
        $progressParams = @{
            Activity        = 'Initiating VMS hardware scan'
            PercentComplete = 0
        }
        try {
            switch ($PSCmdlet.ParameterSetName) {
                'Express' {
                    $logins = @()
                    foreach ($c in $Credential) {
                        $logins += [pscustomobject]@{
                            User = $c.UserName
                            Pass = $c.GetNetworkCredential().Password
                        }
                    }
                    try {
                        foreach ($recorder in $RecordingServer) {
                            $progressParams.PercentComplete = [int]($tasks.Count / $RecordingServer.Count * 100)
                            Write-Progress @progressParams
                            $recorderPathMap.($recorder.Path) = $recorder
                            $tasks += $recorder.HardwareScanExpress($logins[0].User, $logins[0].Pass, $logins[1].User, $logins[1].Pass, $logins[2].User, $logins[2].Pass, ($null -eq $Credential -or $UseDefaultCredentials), $UseHttps)
                        }
                    } catch {
                        throw
                    }
                }

                'Manual' {
                    $rangeParameters = ($MyInvocation.BoundParameters.Keys | Where-Object { $_ -in @('Start', 'End') }).Count
                    if ($rangeParameters -eq 1) {
                        Write-Error 'When using the Start or End parameters, you must provide both Start and End parameter values'
                        return
                    }
                    if ($Credential.Count -gt 1) {
                        Write-Warning "Manual address/range scanning supports the use of default credentials and only one user-supplied credential. Only the first of the $($Credential.Count) credentials provided in the Credential parameter will be used."
                    }
                    $Address = $Address | ForEach-Object {
                        if ($_.IsAbsoluteUri) {
                            $_
                        } else {
                            [uri]"http://$($_.OriginalString)"
                        }
                    }
                    if ($MyInvocation.BoundParameters.ContainsKey('UseHttps') -or $MyInvocation.BoundParameters.ContainsKey('HttpPort')) {
                        $Address = $Address | Foreach-Object {
                            $a = [uribuilder]$_
                            if ($MyInvocation.BoundParameters.ContainsKey('UseHttps')) {
                                $a.Scheme = if ($UseHttps) { 'https' } else { 'http' }
                            }
                            if ($MyInvocation.BoundParameters.ContainsKey('HttpPort')) {
                                $a.Port = $HttpPort
                            }
                            $a.Uri
                        }
                    }
                    if ($MyInvocation.BoundParameters.ContainsKey('Start')) {
                        $Address += Expand-IPRange -Start $Start -End $End | ConvertTo-Uri -UseHttps:$UseHttps -HttpPort $HttpPort
                    }
                    if ($MyInvocation.BoundParameters.ContainsKey('Cidr')) {
                        $Address += Expand-IPRange -Cidr $Cidr | Select-Object -Skip 1 | Select-Object -SkipLast 1 | ConvertTo-Uri -UseHttps:$UseHttps -HttpPort $HttpPort
                    }

                    foreach ($entry in $Address) {
                        try {
                            $user, $pass = $null
                            if ($Credential.Count -gt 0) {
                                $user = $Credential[0].UserName
                                $pass = $Credential[0].Password
                            }
                            foreach ($recorder in $RecordingServer) {
                                $progressParams.PercentComplete = [int]($tasks.Count / ($Address.Count * $RecordingServer.Count) * 100)
                                Write-Progress @progressParams
                                if ($MyInvocation.BoundParameters.ContainsKey('DriverFamily')) {
                                    $DriverNumber += $recorder | Get-HardwareDriver | Where-Object { $_.GroupName -in $DriverFamily -and $_.Number -notin $DriverNumber } | Select-Object -ExpandProperty Number
                                }
                                if ($DriverNumber.Count -eq 0) {
                                    Write-Warning "Start-VmsHardwareScan is about to scan $($Address.Count) addresses from $($recorder.Name) without specifying one or more hardware device drivers. This can take a very long time."
                                }
                                $driverNumbers = $DriverNumber -join ';'
                                Write-Verbose "Adding HardwareScan task for $($entry) using driver numbers $driverNumbers"
                                $recorderPathMap.($recorder.Path) = $recorder
                                $tasks += $RecordingServer.HardwareScan($entry.ToString(), $driverNumbers, $user, $pass, ($null -eq $Credential -or $UseDefaultCredentials))
                            }
                        } catch {
                            throw
                        }
                    }
                }
            }
        } finally {
            $progressParams.Completed = $true
            $progressParams.PercentComplete = 100
            Write-Progress @progressParams
        }

        if ($PassThru) {
            Write-Output $tasks
        } else {
            Wait-VmsTask -Path $tasks.Path -Title "Running $(($PSCmdlet.ParameterSetName).ToLower()) hardware scan" -Cleanup | Foreach-Object {
                $state = $_.Properties | Where-Object Key -eq 'State'
                if ($state.Value -eq 'Error') {
                    $errorText = $_.Properties | Where-Object Key -eq 'ErrorText'
                    Write-Error $errorText.Value
                } else {
                    $results = if ($_.Children.Count -gt 0) { [VmsHardwareScanResult[]]$_.Children } else {
                        [VmsHardwareScanResult]$_
                    }
                    foreach ($result in $results) {
                        $result.RecordingServer = $recorderPathMap.($_.ParentPath)
                        # TODO: Remove this entire if block when bug 487881 is fixed and hotfixes for supported versions are available.
                        if ($result.MacAddressExistsLocal) {
                            if ($result.MacAddress -notin ($result.RecordingServer | Get-Hardware | Get-HardwareSetting).MacAddress) {
                                Write-Verbose "MacAddress $($result.MacAddress) incorrectly reported as already existing on recorder. Changing MacAddressExistsLocal to false."
                                $result.MacAddressExistsLocal = $false
                            }
                        }
                        Write-Output $result
                    }
                }
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Start-VmsHardwareScan -ParameterName RecordingServer -ScriptBlock {
    $values = (Get-VmsRecordingServer).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Wait-VmsTask {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [ValidateScript({
            if ($_ -notmatch '^Task\[\d+\]$') {
                throw "Path must match the Configuration API Task path format 'Task[number]' where number is an integer.'"
            }
            return $true
        })]
        [string[]]
        $Path,

        [Parameter()]
        [string]
        $Title,

        [Parameter()]
        [switch]
        $Cleanup
    )

    process {
        $tasks = New-Object 'System.Collections.Generic.Queue[VideoOS.ConfigurationApi.ClientService.ConfigurationItem]'
        $Path | Foreach-Object {
            $item = $null
            $errorCount = 0
            while ($null -eq $item) {
                try {
                    $item = Get-ConfigurationItem -Path $_
                }
                catch {
                    $errorCount++
                    if ($errorCount -ge 5) {
                        throw
                    }
                    else {
                        Write-Verbose 'Wait-VmsTask received an error when communicating with Configuration API. The communication channel will be re-established and the connection will be attempted up to 5 times.'
                        Start-Sleep -Seconds 2
                        Get-Site | Select-Site
                    }
                }
            }

            if ($item.ItemType -ne 'Task') {
                Write-Error "Configuration Item with path '$($item.Path)' is incompatible with Wait-VmsTask. Expected an ItemType of 'Task' and received a '$($item.ItemType)'."
            }
            else {
                $tasks.Enqueue($item)
            }
        }
        $completedStates = 'Error', 'Success', 'Completed'
        $totalTasks = $tasks.Count
        $progressParams = @{
            Activity = if ([string]::IsNullOrWhiteSpace($Title)) { 'Waiting for VMS Task(s) to complete' } else { $Title }
            PercentComplete = 0
            Status = 'Processing'
        }
        try {
            Write-Progress @progressParams
            $stopwatch = [diagnostics.stopwatch]::StartNew()
            while ($tasks.Count -gt 0) {
                Start-Sleep -Milliseconds 500
                $taskInfo = $tasks.Dequeue()
                $completedTaskCount = $totalTasks - ($tasks.Count + 1)
                $tasksRemaining = $totalTasks - $completedTaskCount
                $percentComplete = [int]($taskInfo.Properties | Where-Object Key -eq 'Progress' | Select-Object -ExpandProperty Value)

                if ($completedTaskCount -gt 0) {
                    $timePerTask = $stopwatch.ElapsedMilliseconds / $completedTaskCount
                    $remainingTime = [timespan]::FromMilliseconds($tasksRemaining * $timePerTask)
                    $progressParams.SecondsRemaining = [int]$remainingTime.TotalSeconds
                }
                elseif ($percentComplete -gt 0){
                    $pointsRemaining = 100 - $percentComplete
                    $timePerPoint = $stopwatch.ElapsedMilliseconds / $percentComplete
                    $remainingTime = [timespan]::FromMilliseconds($pointsRemaining * $timePerPoint)
                    $progressParams.SecondsRemaining = [int]$remainingTime.TotalSeconds
                }

                if ($tasks.Count -eq 0) {
                    $progressParams.Status = "$($taskInfo.Path) - $($taskInfo.DisplayName)."
                    $progressParams.PercentComplete = $percentComplete
                    Write-Progress @progressParams
                }
                else {
                    $progressParams.Status = "Completed $completedTaskCount of $totalTasks tasks. Remaining tasks: $tasksRemaining"
                    $progressParams.PercentComplete = [int]($completedTaskCount / $totalTasks * 100)
                    Write-Progress @progressParams
                }
                $errorCount = 0
                while ($null -eq $taskInfo) {
                    try {
                        $taskInfo = $taskInfo | Get-ConfigurationItem
                        break
                    }
                    catch {
                        $errorCount++
                        if ($errorCount -ge 5) {
                            throw
                        }
                        else {
                            Write-Verbose 'Wait-VmsTask received an error when communicating with Configuration API. The communication channel will be re-established and the connection will be attempted up to 5 times.'
                            Start-Sleep -Seconds 2
                            Get-Site | Select-Site
                        }
                    }
                }
                $taskInfo = $taskInfo | Get-ConfigurationItem
                if (($taskInfo | Get-ConfigurationItemProperty -Key State) -notin $completedStates) {
                    $tasks.Enqueue($taskInfo)
                    continue
                }
                Write-Output $taskInfo
                if ($Cleanup -and $taskInfo.MethodIds -contains 'TaskCleanup') {
                    $null = $taskInfo | Invoke-Method -MethodId 'TaskCleanup'
                }
            }
        }
        finally {
            $progressParams.Completed = $true
            Write-Progress @progressParams
        }
    }
}
function Get-LicenseDetails {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.LicenseDetailChildItem])]
    param ()

    begin {
        Assert-VmsVersion -MinimumVersion 20.2 -ErrorAction Stop -Comment "Management of Milestone XProtect VMS licensing using MIP SDK was introduced in version 2020 R2 (v20.2). This function is not compatible with your current Management Server version."
    }

    process {
        (Get-LicenseInfo).LicenseDetailFolder.LicenseDetailChildItems
    }
}
function Get-LicensedProducts {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.LicenseInstalledProductChildItem])]
    param ()

    process {
        (Get-LicenseInfo).LicenseInstalledProductFolder.LicenseInstalledProductChildItems
    }
}
function Get-LicenseInfo {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.LicenseInformation])]
    param ()

    begin {
        Assert-VmsVersion -MinimumVersion 20.2 -ErrorAction Stop -Comment "Management of Milestone XProtect VMS licensing using MIP SDK was introduced in version 2020 R2 (v20.2). This function is not compatible with your current Management Server version."
    }

    process {
        $site = Get-Site
        [VideoOS.Platform.ConfigurationItems.LicenseInformation]::new($site.FQID.ServerId, "LicenseInformation[$($site.FQID.ObjectId)]")
    }
}
function Get-LicenseOverview {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.LicenseOverviewAllChildItem])]
    param ()

    process {
        $licenseInfo = Get-LicenseInfo
        $licenseInfo.LicenseOverviewAllFolder.LicenseOverviewAllChildItems
    }
}
function Invoke-LicenseActivation {
    [CmdletBinding()]
    param (
        # Specifies the My Milestone credentials to use for the license activation request
        [Parameter(mandatory)]
        [pscredential]
        $Credential,

        # Specifies whether the provided credentials should be saved and re-used for automatic license activation
        [Parameter()]
        [switch]
        $EnableAutomaticActivation,

        # Specifies that the result of Get-LicenseDetails should be passed into the pipeline after activatino
        [Parameter()]
        [switch]
        $Passthru
    )
    
    process {
        $licenseInfo = Get-LicenseInfo
        $invokeResult = $licenseInfo.ActivateLicense($Credential.UserName, $Credential.Password, $EnableAutomaticActivation)
        do {
            $task = $invokeResult | Get-ConfigurationItem
            $state = $task | Get-ConfigurationItemProperty -Key State
            Write-Verbose ([string]::Join(', ', $task.Properties.Key))
            Start-Sleep -Seconds 1
        } while ($state -ne 'Error' -and $state -ne 'Success')
        if ($state -ne 'Success') {
            Write-Error ($task | Get-ConfigurationItemProperty -Key 'ErrorText')
        }

        if ($Passthru) {
            Get-LicenseDetails
        }
    }
}
function Get-MobileServerInfo {
    [CmdletBinding()]
    param ( )
    process {
        try {
            $mobServerPath = Get-ItemPropertyValue -Path 'HKLM:\SOFTWARE\WOW6432Node\Milestone\XProtect Mobile Server' -Name INSTALLATIONFOLDER
            [Xml]$doc = Get-Content "$mobServerPath.config" -ErrorAction Stop

            $xpath = "/configuration/ManagementServer/Address/add[@key='Ip']"
            $msIp = $doc.SelectSingleNode($xpath).Attributes['value'].Value
            $xpath = "/configuration/ManagementServer/Address/add[@key='Port']"
            $msPort = $doc.SelectSingleNode($xpath).Attributes['value'].Value

            $xpath = "/configuration/HttpMetaChannel/Address/add[@key='Port']"
            $httpPort = [int]::Parse($doc.SelectSingleNode($xpath).Attributes['value'].Value)
            $xpath = "/configuration/HttpMetaChannel/Address/add[@key='Ip']"
            $httpIp = $doc.SelectSingleNode($xpath).Attributes['value'].Value
            if ($httpIp -eq '+') { $httpIp = '0.0.0.0'}

            $xpath = "/configuration/HttpSecureMetaChannel/Address/add[@key='Port']"
            $httpsPort = [int]::Parse($doc.SelectSingleNode($xpath).Attributes['value'].Value)
            $xpath = "/configuration/HttpSecureMetaChannel/Address/add[@key='Ip']"
            $httpsIp = $doc.SelectSingleNode($xpath).Attributes['value'].Value
            if ($httpsIp -eq '+') { $httpsIp = '0.0.0.0'}
            try {
                $hash = Get-HttpSslCertThumbprint -IPPort "$($httpsIp):$($httpsPort)" -ErrorAction Stop
            } catch {
                $hash = $null
            }
            $info = [PSCustomObject]@{
                Version = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($mobServerPath).FileVersion;
                ExePath = $mobServerPath;
                ConfigPath = "$mobServerPath.config";
                ManagementServerIp = $msIp;
                ManagementServerPort = $msPort;
                HttpIp = $httpIp;
                HttpPort = $httpPort;
                HttpsIp = $httpsIp;
                HttpsPort = $httpsPort;
                CertHash = $hash
            }
            $info
        } catch {
            Write-Error $_
        }
    }
}
function Set-XProtectCertificate {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        # Specifies the Milestone component on which to update the certificate
        # - Server: Applies to communication between Management Server and Recording Server, as well as client connections to the HTTPS port for the Management Server.
        # - StreamingMedia: Applies to all connections to Recording Servers. Typically on port 7563.
        # - MobileServer: Applies to HTTPS connections to the Milestone Mobile Server.
        [Parameter(Mandatory)]
        [ValidateSet('Server', 'StreamingMedia', 'MobileServer', 'EventServer')]
        [string]
        $VmsComponent,

        # Specifies that encryption for the specified Milestone XProtect service should be disabled
        [Parameter(ParameterSetName = 'Disable')]
        [switch]
        $Disable,

        # Specifies the thumbprint of the certificate to apply to Milestone XProtect service
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Enable')]
        [string]
        $Thumbprint,

        # Specifies the Windows user account for which read access to the private key is required
        [Parameter(ParameterSetName = 'Enable')]
        [string]
        $UserName,

        # Specifies the path to the Milestone Server Configurator executable. The default location is C:\Program Files\Milestone\Server Configurator\ServerConfigurator.exe
        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]
        $ServerConfiguratorPath = 'C:\Program Files\Milestone\Server Configurator\ServerConfigurator.exe',

        # Specifies that all certificates issued to
        [Parameter(ParameterSetName = 'Enable')]
        [switch]
        $RemoveOldCert,

        # Specifies that the Server Configurator process should be terminated if it's currently running
        [switch]
        $Force
    )

    begin {
        Assert-IsAdmin

        $certGroups = @{
            Server         = '84430eb7-847c-422d-aa00-7915cd0d7a65'
            StreamingMedia = '549df21d-047c-456b-958e-99e65dd8b3ec'
            MobileServer   = '76cfc719-a852-4210-913e-703eadab139a'
            EventServer    = '7e02e0f5-549d-4113-b8de-bda2c1f38dbf'
        }

        $knownExitCodes = @{
            0  = 'Success'
            -1 = 'Unknown error'
            -2 = 'Invalid arguments'
            -3 = 'Invalid argument value'
            -4 = 'Another instance is running'
        }
    }

    process {
        $utility = [IO.FileInfo]$ServerConfiguratorPath
        if (-not $utility.Exists) {
            $exception = [System.IO.FileNotFoundException]::new("Milestone Server Configurator not found at $ServerConfiguratorPath", $utility.FullName)
            Write-Error -Message $exception.Message -Exception $exception
            return
        }
        if ($utility.VersionInfo.FileVersion -lt [version]'20.3') {
            Write-Error "Server Configurator version 20.3 is required as the command-line interface for Server Configurator was introduced in version 2020 R3. The current version appears to be $($utility.VersionInfo.FileVersion). Please upgrade to version 2020 R3 or greater."
            return
        }
        Write-Verbose "Verified Server Configurator version $($utility.VersionInfo.FileVersion) is available at $ServerConfiguratorPath"

        $newCert = Get-ChildItem -Path "Cert:\LocalMachine\My\$Thumbprint" -ErrorAction Ignore
        if ($null -eq $newCert -and -not $Disable) {
            Write-Error "Certificate not found in Cert:\LocalMachine\My with thumbprint '$Thumbprint'. Please make sure the certificate is installed in the correct certificate store."
            return
        } elseif ($Thumbprint) {
            Write-Verbose "Located certificate in Cert:\LocalMachine\My with thumbprint $Thumbprint"
        }

        # Add read access to the private key for the specified certificate if UserName was specified
        if (-not [string]::IsNullOrWhiteSpace($UserName)) {
            try {
                Write-Verbose "Ensuring $UserName has the right to read the private key for the specified certificate"
                $newCert | Set-CertKeyPermission -UserName $UserName
            } catch {
                Write-Error -Message "Error granting user '$UserName' read access to the private key for certificate with thumbprint $Thumbprint" -Exception $_.Exception
            }
        }

        if ($Force) {
            if ($PSCmdlet.ShouldProcess("ServerConfigurator", "Kill process if running")) {
                Get-Process -Name ServerConfigurator -ErrorAction Ignore | Foreach-Object {
                    Write-Verbose 'Server Configurator is currently running. The Force switch was provided so it will be terminated.'
                    $_ | Stop-Process
                }
            }
        }

        $procParams = @{
            FilePath               = $utility.FullName
            Wait                   = $true
            PassThru               = $true
            RedirectStandardOutput = Join-Path -Path ([system.environment]::GetFolderPath([system.environment+specialfolder]::ApplicationData)) -ChildPath ([io.path]::GetRandomFileName())
        }
        if ($Disable) {
            $procParams.ArgumentList = '/quiet', '/disableencryption', "/certificategroup=$($certGroups.$VmsComponent)"
        } else {
            $procParams.ArgumentList = '/quiet', '/enableencryption', "/certificategroup=$($certGroups.$VmsComponent)", "/thumbprint=$Thumbprint"
        }
        $argumentString = [string]::Join(' ', $procParams.ArgumentList)
        Write-Verbose "Running Server Configurator with the following arguments: $argumentString"

        if ($PSCmdlet.ShouldProcess("ServerConfigurator", "Start process with arguments '$argumentString'")) {
            $result = Start-Process @procParams
            if ($result.ExitCode -ne 0) {
                Write-Error "Server Configurator exited with code $($result.ExitCode). $($knownExitCodes.$($result.ExitCode))"
                return
            }
        }

        if ($RemoveOldCert) {
            $oldCerts = Get-ChildItem -Path "Cert:\LocalMachine\My" | Where-Object { $_.Subject -eq $newCert.Subject -and $_.Thumbprint -ne $newCert.Thumbprint }
            if ($null -eq $oldCerts) {
                Write-Verbose "No other certificates found matching the subject name $($newCert.Subject)"
                return
            }
            foreach ($cert in $oldCerts) {
                if ($PSCmdlet.ShouldProcess($cert.Thumbprint, "Remove certificate from certificate store")) {
                    Write-Verbose "Removing certificate with thumbprint $($cert.Thumbprint)"
                    $cert | Remove-Item
                }
            }
        }
    }
}
function Get-CameraRecordingStats {
    [CmdletBinding()]
    param(
        # Specifies the Id's of cameras for which to retrieve recording statistics
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [guid[]]
        $Id,

        # Specifies the timestamp from which to start retrieving recording statistics. Default is 7 days prior to 12:00am of the current day.
        [Parameter()]
        [datetime]
        $StartTime = (Get-Date).Date.AddDays(-7),

        # Specifies the timestamp marking the end of the time period for which to retrieve recording statistics. The default is 12:00am of the current day.
        [Parameter()]
        [datetime]
        $EndTime = (Get-Date).Date,

        # Specifies the type of sequence to get statistics on. Default is RecordingSequence.
        [Parameter()]
        [ValidateSet('RecordingSequence', 'MotionSequence')]
        [string]
        $SequenceType = 'RecordingSequence',

        # Specifies that the output should be provided in a complete hashtable instead of one pscustomobject value at a time
        [Parameter()]
        [switch]
        $AsHashTable,

        # Specifies the runspacepool to use. If no runspacepool is provided, one will be created.
        [Parameter()]
        [System.Management.Automation.Runspaces.RunspacePool]
        $RunspacePool
    )

    process {
        if ($EndTime -le $StartTime) {
            throw "EndTime must be greater than StartTime"
        }

        $disposeRunspacePool = $true
        if ($PSBoundParameters.ContainsKey('RunspacePool')) {
            $disposeRunspacePool = $false
        }
        $pool = $RunspacePool
        if ($null -eq $pool) {
            Write-Verbose "Creating a runspace pool"
            $pool = [runspacefactory]::CreateRunspacePool(1, ([int]$env:NUMBER_OF_PROCESSORS + 1))
            $pool.Open()
        }

        $scriptBlock = {
            param(
                [guid]$Id,
                [datetime]$StartTime,
                [datetime]$EndTime,
                [string]$SequenceType
            )

            $sequences = Get-SequenceData -Path "Camera[$Id]" -SequenceType $SequenceType -StartTime $StartTime -EndTime $EndTime -CropToTimeSpan
            $recordedMinutes = $sequences | Foreach-Object {
                ($_.EventSequence.EndDateTime - $_.EventSequence.StartDateTime).TotalMinutes
                } | Measure-Object -Sum | Select-Object -ExpandProperty Sum
            [pscustomobject]@{
                DeviceId = $Id
                StartTime = $StartTime
                EndTime = $EndTime
                SequenceCount = $sequences.Count
                TimeRecorded = [timespan]::FromMinutes($recordedMinutes)
                PercentRecorded = [math]::Round(($recordedMinutes / ($EndTime - $StartTime).TotalMinutes * 100), 1)
            }
        }

        try {
            $threads = New-Object System.Collections.Generic.List[pscustomobject]
            foreach ($cameraId in $Id) {
                $ps = [powershell]::Create()
                $ps.RunspacePool = $pool
                $asyncResult = $ps.AddScript($scriptBlock).AddParameters(@{
                    Id = $cameraId
                    StartTime = $StartTime
                    EndTime = $EndTime
                    SequenceType = $SequenceType
                }).BeginInvoke()
                $threads.Add([pscustomobject]@{
                    DeviceId = $cameraId
                    PowerShell = $ps
                    Result = $asyncResult
                })
            }

            if ($threads.Count -eq 0) {
                return
            }

            $hashTable = @{}
            $completedThreads = New-Object System.Collections.Generic.List[pscustomobject]
            while ($threads.Count -gt 0) {
                foreach ($thread in $threads) {
                    if ($thread.Result.IsCompleted) {
                        if ($AsHashTable) {
                            $hashTable.$($thread.DeviceId.ToString()) = $null
                        }
                        else {
                            $obj = [ordered]@{
                                DeviceId = $thread.DeviceId.ToString()
                                RecordingStats = $null
                            }
                        }
                        try {
                            $result = $thread.PowerShell.EndInvoke($thread.Result) | ForEach-Object { Write-Output $_ }
                            if ($AsHashTable) {
                                $hashTable.$($thread.DeviceId.ToString()) = $result
                            }
                            else {
                                $obj.RecordingStats = $result
                            }
                        }
                        catch {
                            Write-Error $_
                        }
                        finally {
                            $thread.PowerShell.Dispose()
                            $completedThreads.Add($thread)
                            if (!$AsHashTable) {
                                Write-Output ([pscustomobject]$obj)
                            }
                        }
                    }
                }
                $completedThreads | Foreach-Object { [void]$threads.Remove($_)}
                $completedThreads.Clear()
                if ($threads.Count -eq 0) {
                    break;
                }
                Start-Sleep -Milliseconds 250
            }
            if ($AsHashTable) {
                Write-Output $hashTable
            }
        }
        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()
                }
            }
            if ($disposeRunspacePool) {
                Write-Verbose "Closing runspace pool in $($MyInvocation.MyCommand.Name)"
                $pool.Close()
                $pool.Dispose()
            }
        }
    }
}
function Get-CurrentDeviceStatus {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param (
        # Specifies one or more Recording Server ID's to which the results will be limited. Omit this parameter if you want device status from all Recording Servers
        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Id')]
        [guid[]]
        $RecordingServerId,

        # Specifies the type of devices to include in the results. By default only cameras will be included and you can expand this to include all device types
        [Parameter()]
        [ValidateSet('Camera', 'Microphone', 'Speaker', 'Metadata', 'Input event', 'Output', 'Event', 'Hardware', 'All')]
        [string[]]
        $DeviceType = 'Camera',

        # Specifies that the output should be provided in a complete hashtable instead of one pscustomobject value at a time
        [Parameter()]
        [switch]
        $AsHashTable,

        # Specifies the runspacepool to use. If no runspacepool is provided, one will be created.
        [Parameter()]
        [System.Management.Automation.Runspaces.RunspacePool]
        $RunspacePool
    )

    process {
        if ($DeviceType -contains 'All') {
            $DeviceType = @('Camera', 'Microphone', 'Speaker', 'Metadata', 'Input event', 'Output', 'Event', 'Hardware')
        }
        $includedDeviceTypes = $DeviceType | Foreach-Object { [videoos.platform.kind]::$_ }

        $disposeRunspacePool = $true
        if ($PSBoundParameters.ContainsKey('RunspacePool')) {
            $disposeRunspacePool = $false
        }
        $pool = $RunspacePool
        if ($null -eq $pool) {
            Write-Verbose "Creating a runspace pool"
            $iss = [initialsessionstate]::CreateDefault()
            $iss.ImportPSModule((Get-Module MipSdkRedist).Path)
            $iss.ImportPSModule((Get-Module MilestonePSTools).Path)
            $pool = [runspacefactory]::CreateRunspacePool(1, ([int]$env:NUMBER_OF_PROCESSORS + 1), $iss, (Get-Host))
            $pool.Open()
        }

        $scriptBlock = {
            param(
                [uri]$Uri,
                [guid[]]$DeviceIds
            )
            try {
                $client = [VideoOS.Platform.SDK.Proxy.Status2.RecorderStatusService2]::new($Uri)
                $client.GetCurrentDeviceStatus((Get-VmsToken), $deviceIds)
            }
            catch {
                throw "Unable to get current device status from $Uri"
            }
        }

        Write-Verbose 'Retrieving recording server information'
        $managementServer = [videoos.platform.configuration]::Instance.GetItems([videoos.platform.itemhierarchy]::SystemDefined) | Where-Object { $_.FQID.Kind -eq [videoos.platform.kind]::Server -and $_.FQID.ObjectId -eq (Get-VmsManagementServer).Id }
        $recorders = $managementServer.GetChildren() | Where-Object { $_.FQID.ServerId.ServerType -eq 'XPCORS' -and ($null -eq $RecordingServerId -or $_.FQID.ObjectId -in $RecordingServerId) }
        Write-Verbose "Retrieving video device statistics from $($recorders.Count) recording servers"
        try {
            $threads = New-Object System.Collections.Generic.List[pscustomobject]
            foreach ($recorder in $recorders) {
                Write-Verbose "Requesting device status from $($recorder.Name) at $($recorder.FQID.ServerId.Uri)"
                $folders = $recorder.GetChildren() | Where-Object { $_.FQID.Kind -in $includedDeviceTypes -and $_.FQID.FolderType -eq [videoos.platform.foldertype]::SystemDefined}
                $deviceIds = [guid[]]($folders | Foreach-Object {
                    $children = $_.GetChildren()
                    if ($null -ne $children -and $children.Count -gt 0) {
                        $children.FQID.ObjectId
                    }
                })

                $ps = [powershell]::Create()
                $ps.RunspacePool = $pool
                $asyncResult = $ps.AddScript($scriptBlock).AddParameters(@{
                    Uri = $recorder.FQID.ServerId.Uri
                    DeviceIds = $deviceIds
                }).BeginInvoke()
                $threads.Add([pscustomobject]@{
                    RecordingServerId = $recorder.FQID.ObjectId
                    RecordingServerName = $recorder.Name
                    PowerShell = $ps
                    Result = $asyncResult
                })
            }

            if ($threads.Count -eq 0) {
                return
            }

            $hashTable = @{}
            $completedThreads = New-Object System.Collections.Generic.List[pscustomobject]
            while ($threads.Count -gt 0) {
                foreach ($thread in $threads) {
                    if ($thread.Result.IsCompleted) {
                        Write-Verbose "Receiving results from recording server $($thread.RecordingServerName)"
                        if ($AsHashTable) {
                            $hashTable.$($thread.RecordingServerId.ToString()) = $null
                        }
                        else {
                            $obj = @{
                                RecordingServerId = $thread.RecordingServerId.ToString()
                                CurrentDeviceStatus = $null
                            }
                        }
                        try {
                            $result = $thread.PowerShell.EndInvoke($thread.Result) | ForEach-Object { Write-Output $_ }
                            if ($AsHashTable) {
                                $hashTable.$($thread.RecordingServerId.ToString()) = $result
                            }
                            else {
                                $obj.CurrentDeviceStatus = $result
                            }
                        }
                        catch {
                            Write-Error $_
                        }
                        finally {
                            $thread.PowerShell.Dispose()
                            $completedThreads.Add($thread)
                            if (!$AsHashTable) {
                                Write-Output ([pscustomobject]$obj)
                            }
                        }
                    }
                }
                $completedThreads | Foreach-Object { [void]$threads.Remove($_)}
                $completedThreads.Clear()
                if ($threads.Count -eq 0) {
                    break;
                }
                Start-Sleep -Milliseconds 250
            }
            if ($AsHashTable) {
                Write-Output $hashTable
            }
        }
        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()
                }
            }
            if ($disposeRunspacePool) {
                Write-Verbose "Closing runspace pool in $($MyInvocation.MyCommand.Name)"
                $pool.Close()
                $pool.Dispose()
            }
        }
    }
}
function Get-VideoDeviceStatistics {
    [CmdletBinding()]
    param (
        # Specifies one or more Recording Server ID's to which the results will be limited. Omit this parameter if you want device status from all Recording Servers
        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Id')]
        [guid[]]
        $RecordingServerId,

        # Specifies that the output should be provided in a complete hashtable instead of one pscustomobject value at a time
        [Parameter()]
        [switch]
        $AsHashTable,

        # Specifies the runspacepool to use. If no runspacepool is provided, one will be created.
        [Parameter()]
        [System.Management.Automation.Runspaces.RunspacePool]
        $RunspacePool
    )

    process {
        $disposeRunspacePool = $true
        if ($PSBoundParameters.ContainsKey('RunspacePool')) {
            $disposeRunspacePool = $false
        }
        $pool = $RunspacePool
        if ($null -eq $pool) {
            Write-Verbose "Creating a runspace pool"
            $pool = [runspacefactory]::CreateRunspacePool(1, ([int]$env:NUMBER_OF_PROCESSORS + 1))
            $pool.Open()
        }

        $scriptBlock = {
            param(
                [uri]$Uri,
                [guid[]]$DeviceIds
            )
            try {
                $client = [VideoOS.Platform.SDK.Proxy.Status2.RecorderStatusService2]::new($Uri)
                $client.GetVideoDeviceStatistics((Get-VmsToken), $deviceIds)
            }
            catch {
                throw "Unable to get video device statistics from $Uri"
            }

        }

        Write-Verbose 'Retrieving recording server information'
        $managementServer = [videoos.platform.configuration]::Instance.GetItems([videoos.platform.itemhierarchy]::SystemDefined) | Where-Object { $_.FQID.Kind -eq [videoos.platform.kind]::Server -and $_.FQID.ObjectId -eq (Get-VmsManagementServer).Id }
        $recorders = $managementServer.GetChildren() | Where-Object { $_.FQID.ServerId.ServerType -eq 'XPCORS' -and ($null -eq $RecordingServerId -or $_.FQID.ObjectId -in $RecordingServerId) }
        Write-Verbose "Retrieving video device statistics from $($recorders.Count) recording servers"
        try {
            $threads = New-Object System.Collections.Generic.List[pscustomobject]
            foreach ($recorder in $recorders) {
                Write-Verbose "Requesting video device statistics from $($recorder.Name) at $($recorder.FQID.ServerId.Uri)"
                $folders = $recorder.GetChildren() | Where-Object { $_.FQID.Kind -eq [videoos.platform.kind]::Camera -and $_.FQID.FolderType -eq [videoos.platform.foldertype]::SystemDefined}
                $deviceIds = [guid[]]($folders | Foreach-Object {
                    $children = $_.GetChildren()
                    if ($null -ne $children -and $children.Count -gt 0) {
                        $children.FQID.ObjectId
                    }
                })

                $ps = [powershell]::Create()
                $ps.RunspacePool = $pool
                $asyncResult = $ps.AddScript($scriptBlock).AddParameters(@{
                    Uri = $recorder.FQID.ServerId.Uri
                    DeviceIds = $deviceIds
                }).BeginInvoke()
                $threads.Add([pscustomobject]@{
                    RecordingServerId = $recorder.FQID.ObjectId
                    RecordingServerName = $recorder.Name
                    PowerShell = $ps
                    Result = $asyncResult
                })
            }

            if ($threads.Count -eq 0) {
                return
            }

            $hashTable = @{}
            $completedThreads = New-Object System.Collections.Generic.List[pscustomobject]
            while ($threads.Count -gt 0) {
                foreach ($thread in $threads) {
                    if ($thread.Result.IsCompleted) {
                        Write-Verbose "Receiving results from recording server $($thread.RecordingServerName)"
                        if ($AsHashTable) {
                            $hashTable.$($thread.RecordingServerId.ToString()) = $null
                        }
                        else {
                            $obj = @{
                                RecordingServerId = $thread.RecordingServerId.ToString()
                                VideoDeviceStatistics = $null
                            }
                        }
                        try {
                            $result = $thread.PowerShell.EndInvoke($thread.Result) | ForEach-Object { Write-Output $_ }
                            if ($AsHashTable) {
                                $hashTable.$($thread.RecordingServerId.ToString()) = $result
                            }
                            else {
                                $obj.VideoDeviceStatistics = $result
                            }
                        }
                        catch {
                            Write-Error $_
                        }
                        finally {
                            $thread.PowerShell.Dispose()
                            $completedThreads.Add($thread)
                            if (!$AsHashTable) {
                                Write-Output ([pscustomobject]$obj)
                            }
                        }
                    }
                }
                $completedThreads | Foreach-Object { [void]$threads.Remove($_)}
                $completedThreads.Clear()
                if ($threads.Count -eq 0) {
                    break;
                }
                Start-Sleep -Milliseconds 250
            }
            if ($AsHashTable) {
                Write-Output $hashTable
            }
        }
        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()
                }
            }
            if ($disposeRunspacePool) {
                Write-Verbose "Closing runspace pool in $($MyInvocation.MyCommand.Name)"
                $pool.Close()
                $pool.Dispose()
            }
        }
    }
}
function Get-VmsCameraReport {
    [CmdletBinding()]
    param (
        [Parameter()]
        [RecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.RecordingServer[]]
        $RecordingServer,

        [Parameter()]
        [switch]
        $IncludePlainTextPasswords,

        [Parameter()]
        [switch]
        $IncludeRetentionInfo,

        [Parameter()]
        [switch]
        $IncludeRecordingStats,

        [Parameter()]
        [switch]
        $IncludeSnapshots,

        [Parameter()]
        [ValidateRange(1, [int]::MaxValue)]
        [int]
        $SnapshotHeight = 300,

        [Parameter()]
        [ValidateSet('All', 'Disabled', 'Enabled')]
        [string]
        $EnableFilter = 'Enabled'
    )

    begin {
        $ms = Get-VmsManagementServer -ErrorAction Stop
        for ($attempt = 1; $attempt -le 2; $attempt++) {
            try {
                $supportsFillChildren = [version]$ms.Version -ge '20.2'
                $scs = Get-IServerCommandService -ErrorAction Stop
                $config = $scs.GetConfiguration((Get-VmsToken))
                $recorderCameraMap = @{}
                $config.Recorders | Foreach-Object {
                    $deviceList = New-Object System.Collections.Generic.List[guid]
                    $_.Cameras.DeviceId | Foreach-Object { if ($_) { $deviceList.Add($_) } }
                    $recorderCameraMap.($_.RecorderId) = $deviceList
                }
                break
            } catch {
                if ($attempt -ge 2) {
                    throw
                }
                # Typically if an error is thrown here, it's on $scs.GetConfiguration because the
                # IServerCommandService WCF channel is cached and reused, and might be timed out.
                # The Select-Site cmdlet has a side effect of flushing all cached WCF channels.
                Get-Site | Select-Site
            }
        }
        $roleMemberships = (Get-LoginSettings | Where-Object Guid -eq (Get-Site).FQID.ObjectId).GroupMembership
        $isAdmin = (Get-VmsRole -RoleType Adminstrative).Id -in $roleMemberships
        $jobRunner = [LocalJobRunner]@{
            JobPollingInterval = [timespan]::FromMilliseconds(500)
        }
    }

    process {
        try {
            if ($IncludePlainTextPasswords -and -not $isAdmin) {
                Write-Warning $script:Messages.MustBeAdminToReadPasswords
            }
            if (-not $RecordingServer) {
                Write-Verbose $script:Messages.ListingAllRecorders
                $RecordingServer = Get-RecordingServer
            }
            $cache = @{
                DeviceState    = @{}
                PlaybackInfo   = @{}
                Snapshots      = @{}
                Passwords      = @{}
                RecordingStats = @{}
            }

            $ids = @()
            $RecordingServer | Foreach-Object {
                if ($null -ne $recorderCameraMap[[guid]$_.Id] -and $recorderCameraMap[[guid]$_.Id].Count -gt 0) {
                    $ids += $recorderCameraMap[[guid]$_.Id]
                }
            }

            Write-Verbose $script:Messages.CallingGetItemState
            Get-ItemState -CamerasOnly -ErrorAction Ignore | Foreach-Object {
                $cache.DeviceState[$_.FQID.ObjectId] = @{
                    ItemState = $_.State
                }
            }

            Write-Verbose $script:Messages.StartingFillChildrenThreadJob
            $fillChildrenJobs = $RecordingServer | Foreach-Object {
                $jobRunner.AddJob(
                    {
                        param([bool]$supportsFillChildren, [object]$recorder, [string]$EnableFilter, [bool]$getPasswords, [hashtable]$cache)

                        $manualMethod = {
                            param([object]$recorder)
                            $null = $recorder.HardwareDriverFolder.HardwareDrivers
                            $null = $recorder.StorageFolder.Storages.ArchiveStorageFolder.ArchiveStorages
                            $null = $recorder.HardwareFolder.Hardwares.HardwareDriverSettingsFolder.HardwareDriverSettings
                            $null = $recorder.HardwareFolder.Hardwares.CameraFolder.Cameras.StreamFolder.Streams
                            $null = $recorder.HardwareFolder.Hardwares.CameraFolder.Cameras.DeviceDriverSettingsFolder.DeviceDriverSettings
                        }
                        if ($supportsFillChildren) {
                            try {
                                $itemTypes = 'Hardware', 'HardwareDriverFolder', 'HardwareDriver', 'HardwareDriverSettingsFolder', 'HardwareDriverSettings', 'StorageFolder', 'Storage', 'StorageInformation', 'ArchiveStorageFolder', 'ArchiveStorage', 'CameraFolder', 'Camera', 'DeviceDriverSettingsFolder', 'DeviceDriverSettings', 'MotionDetectionFolder', 'MotionDetection', 'StreamFolder', 'Stream', 'StreamSettings', 'StreamDefinition', 'ClientSettings'
                                $alwaysIncludedItemTypes = @('MotionDetection', 'HardwareDriver', 'HardwareDriverSettings', 'Hardware', 'Storage', 'ArchiveStorage', 'DeviceDriverSettings')
                                $supportsPrivacyMask = (Get-IServerCommandService).GetConfiguration((Get-VmsToken)).ServerOptions | Where-Object Key -eq 'PrivacyMask' | Select-Object -ExpandProperty Value
                                if ($supportsPrivacyMask -eq 'True') {
                                    $alwaysIncludedItemTypes += 'PrivacyProtectionFolder', 'PrivacyProtection'
                                }
                                $itemFilters = $itemTypes | Foreach-Object {
                                    $enableFilterSelection = if ($_ -in $alwaysIncludedItemTypes) { 'All' } else { $EnableFilter }
                                    [VideoOS.ConfigurationApi.ClientService.ItemFilter]@{
                                        ItemType        = $_
                                        EnableFilter    = $enableFilterSelection
                                        PropertyFilters = @()
                                    }
                                }
                                $recorder.FillChildren($itemTypes, $itemFilters)

                                # TODO: Remove this after TFS 447559 is addressed. The StreamFolder.Streams collection is empty after using FillChildren
                                # So this entire foreach block is only necessary to flush the children of StreamFolder and force another query for every
                                # camera so we can fill the collection up in this background task before enumerating over everything at the end.
                                foreach ($hw in $recorder.hardwarefolder.hardwares) {
                                    if ($getPasswords) {
                                        $password = $hw.ReadPasswordHardware().GetProperty('Password')
                                        $cache.Passwords[[guid]$hw.Id] = $password
                                    }
                                    foreach ($cam in $hw.camerafolder.cameras) {
                                        try {
                                            if ($null -ne $cam.StreamFolder -and $cam.StreamFolder.Streams.Count -eq 0) {
                                                $cam.StreamFolder.ClearChildrenCache()
                                                $null = $cam.StreamFolder.Streams
                                            }
                                        }
                                        catch {
                                            Write-Error $_
                                        }
                                    }
                                }
                            }
                            catch {
                                Write-Error $_
                                $manualMethod.Invoke($recorder)
                            }
                        }
                        else {
                            $manualMethod.Invoke($recorder)
                        }
                    },
                    @{ SupportsFillChildren = $supportsFillChildren; recorder = $_; EnableFilter = $EnableFilter; getPasswords = ($isAdmin -and $IncludePlainTextPasswords); cache = $cache }
                )
            }

            # Kick off snapshots early if requested. Pick up results at the end.
            $snapshotsById = @{}
            if ($IncludeSnapshots) {
                Write-Verbose "Starting Get-Snapshot threadjob"
                $snapshotScriptBlock = {
                    param([guid[]]$ids, [int]$snapshotHeight, [hashtable]$snapshotsById, [hashtable]$cache)
                    foreach ($id in $ids) {
                        $itemState = $cache.DeviceState[$id].ItemState
                        if (-not [string]::IsNullOrWhiteSpace($itemState) -and $itemState -ne 'Responding') {
                            # Do not attempt to get a live image if the event server says the camera is not responding. Saves time.
                            continue
                        }
                        $snapshot = Get-Snapshot -CameraId $id -Live -Quality 100
                        if ($null -ne $snapshot) {
                            $image = $snapshot | ConvertFrom-Snapshot | Resize-Image -Height $snapshotHeight -DisposeSource
                            $snapshotsById[$id] = $image
                        }
                    }
                }
                $snapshotsJob = $jobRunner.AddJob($snapshotScriptBlock, @{ids = $ids; snapshotHeight = $SnapshotHeight; snapshotsById = $snapshotsById; cache = $cache })
            }

            if ($IncludeRetentionInfo) {
                Write-Verbose 'Starting Get-PlaybackInfo threadjob'
                $playbackInfoScriptblock = {
                    param(
                        [guid]$id,
                        [hashtable]$cache
                    )

                    $info = Get-PlaybackInfo -Path "Camera[$id]"
                    if ($null -ne $info) {
                        $cache.PlaybackInfo[$id] = $info
                    }
                }
                $playbackInfoJobs = $ids | Foreach-Object {
                    # Guarding against a null camera guid here. Could remove this clause with a bit of refactoring
                    # to where the array is built.
                    if ($null -ne $_) {
                        $jobRunner.AddJob($playbackInfoScriptblock, @{ id = $_; cache = $cache } )
                    }
                }
            }

            if ($IncludeRecordingStats) {
                Write-Verbose 'Starting recording stats threadjob'
                $recordingStatsScript = {
                    param(
                        [guid]$Id,
                        [datetime]$StartTime,
                        [datetime]$EndTime,
                        [string]$SequenceType
                    )

                    $sequences = Get-SequenceData -Path "Camera[$Id]" -SequenceType $SequenceType -StartTime $StartTime -EndTime $EndTime -CropToTimeSpan
                    $recordedMinutes = $sequences | Foreach-Object {
                        ($_.EventSequence.EndDateTime - $_.EventSequence.StartDateTime).TotalMinutes
                        } | Measure-Object -Sum | Select-Object -ExpandProperty Sum
                    [pscustomobject]@{
                        DeviceId = $Id
                        StartTime = $StartTime
                        EndTime = $EndTime
                        SequenceCount = $sequences.Count
                        TimeRecorded = [timespan]::FromMinutes($recordedMinutes)
                        PercentRecorded = [math]::Round(($recordedMinutes / ($EndTime - $StartTime).TotalMinutes * 100), 1)
                    }
                }
                $recordingStatsJobs = $ids | Foreach-Object {
                    $jobRunner.AddJob($recordingStatsScript, @{Id = $_; StartTime = (Get-Date).Date.AddDays(-7); EndTime = (Get-Date).Date; SequenceType = 'RecordingSequence'})
                }
            }

            # Get VideoDeviceStatistics for all Recording Servers in the report
            Write-Verbose 'Starting GetVideoDeviceStatistics threadjob'
            $videoDeviceStatsScriptBlock = {
                param(
                    [VideoOS.Platform.SDK.Proxy.Status2.RecorderStatusService2]$svc,
                    [guid[]]$ids
                )
                $svc.GetVideoDeviceStatistics((Get-VmsToken), $ids)
            }
            $videoDeviceStatsJobs = $RecordingServer | ForEach-Object {
                $svc = $_ | Get-RecorderStatusService2
                if ($null -ne $svc) {
                    $jobRunner.AddJob($videoDeviceStatsScriptBlock, @{ svc = $svc; ids = $recorderCameraMap[[guid]$_.Id] })
                }
            }

            # Get Current Device Status for everything in the report
            Write-Verbose 'Starting GetCurrentDeviceStatus threadjob'
            $currentDeviceStatsJobsScriptBlock = {
                param(
                        [VideoOS.Platform.SDK.Proxy.Status2.RecorderStatusService2]$svc,
                        [guid[]]$ids
                    )
                    $svc.GetCurrentDeviceStatus((Get-VmsToken), $ids)
            }
            $currentDeviceStatsJobs = $RecordingServer | Foreach-Object {
                $svc = $_ | Get-RecorderStatusService2
                $jobRunner.AddJob($currentDeviceStatsJobsScriptBlock, @{svc = $svc; ids = $recorderCameraMap[[guid]$_.Id] })
            }

            Write-Verbose 'Receiving results of FillChildren threadjob'
            $jobRunner.Wait($fillChildrenJobs)
            $fillChildrenResults = $jobRunner.ReceiveJobs($fillChildrenJobs)
            foreach ($e in $fillChildrenResults.Errors) {
                Write-Error $e
            }

            if ($IncludeRetentionInfo) {
                Write-Verbose 'Receiving results of Get-PlaybackInfo threadjob'
                $jobRunner.Wait($playbackInfoJobs)
                $playbackInfoResult = $jobRunner.ReceiveJobs($playbackInfoJobs)
                foreach ($e in $playbackInfoResult.Errors) {
                    Write-Error $e
                }
            }

            if ($IncludeRecordingStats) {
                Write-Verbose 'Receiving results of recording stats threadjob'
                $jobRunner.Wait($recordingStatsJobs)
                foreach ($job in $jobRunner.ReceiveJobs($recordingStatsJobs)) {
                    if ($job.Output.DeviceId) {
                        $cache.RecordingStats[$job.Output.DeviceId] = $job.Output
                    }
                    foreach ($e in $job.Errors) {
                        Write-Error $e
                    }
                }
            }

            Write-Verbose 'Receiving results of GetVideoDeviceStatistics threadjobs'
            $jobRunner.Wait($videoDeviceStatsJobs)
            foreach ($job in $jobRunner.ReceiveJobs($videoDeviceStatsJobs)) {
                foreach ($result in $job.Output) {
                    if (-not $cache.DeviceState.ContainsKey($result.DeviceId)) {
                        $cache.DeviceState[$result.DeviceId] = @{}
                    }
                    $cache.DeviceState[$result.DeviceId].UsedSpaceInBytes = $result.UsedSpaceInBytes
                    $cache.DeviceState[$result.DeviceId].VideoStreamStatisticsArray = $result.VideoStreamStatisticsArray
                }
                foreach ($e in $job.Errors) {
                    Write-Error $e
                }
            }

            Write-Verbose 'Receiving results of GetCurrentDeviceStatus threadjobs'
            $jobRunner.Wait($currentDeviceStatsJobs)
            $currentDeviceStatsResult = $jobRunner.ReceiveJobs($currentDeviceStatsJobs)
            $currentDeviceStatsResult.Output | Foreach-Object {
                foreach ($row in $_.CameraDeviceStatusArray) {
                    if (-not $cache.DeviceState.ContainsKey($row.DeviceId)) {
                        $cache.DeviceState[$row.DeviceId] = @{}
                    }
                    $cache.DeviceState[$row.DeviceId].Status = $row
                }
            }
            foreach ($e in $currentDeviceStatsResult.Errors) {
                Write-Error $e
            }

            if ($null -ne $snapshotsJob) {
                Write-Verbose 'Receiving results of Get-Snapshot threadjob'
                $jobRunner.Wait($snapshotsJob)
                $snapshotsResult = $jobRunner.ReceiveJobs($snapshotsJob)
                $cache.Snapshots = $snapshotsById
                foreach ($e in $snapshotsResult.Errors) {
                    Write-Error $e
                }
            }

            foreach ($rec in $RecordingServer) {
                foreach ($hw in $rec.HardwareFolder.Hardwares | Where-Object { if ($EnableFilter -eq 'All') { $true } else { $_.Enabled } }) {
                    try {
                        $hwSettings = ConvertFrom-ConfigurationApiProperties -Properties $hw.HardwareDriverSettingsFolder.HardwareDriverSettings[0].HardwareDriverSettingsChildItems[0].Properties -UseDisplayNames
                        $driver = $rec.HardwareDriverFolder.HardwareDrivers | Where-Object Path -eq $hw.HardwareDriverPath
                        foreach ($cam in $hw.CameraFolder.Cameras | Where-Object { if ($EnableFilter -eq 'All') { $true } elseif ($EnableFilter -eq 'Enabled') { $_.Enabled -and $hw.Enabled } else { !$_.Enabled -or !$hw.Enabled } }) {
                            $id = [guid]$cam.Id
                            $state = $cache.DeviceState[$id]
                            $storage = $rec.StorageFolder.Storages | Where-Object Path -eq $cam.RecordingStorage
                            $motion = $cam.MotionDetectionFolder.MotionDetections[0]
                            if ($cam.StreamFolder.Streams.Count -gt 0) {
                                $liveStreamSettings = $cam | Get-VmsCameraStream -LiveDefault -ErrorAction Ignore
                                $liveStreamStats = $state.VideoStreamStatisticsArray | Where-Object StreamId -eq $liveStreamSettings.StreamReferenceId
                                $recordedStreamSettings = $cam | Get-VmsCameraStream -Recorded -ErrorAction Ignore
                                $recordedStreamStats = $state.VideoStreamStatisticsArray | Where-Object StreamId -eq $recordedStreamSettings.StreamReferenceId
                            }
                            else {
                                Write-Warning "Live & recorded stream properties unavailable for $($cam.Name) as the camera does not support multi-streaming."
                            }
                            $obj = [ordered]@{
                                Name                         = $cam.Name
                                Channel                      = $cam.Channel
                                Enabled                      = $cam.Enabled -and $hw.Enabled
                                ShortName                    = $cam.ShortName
                                Shortcut                     = $cam.ClientSettingsFolder.ClientSettings.Shortcut
                                State                        = $state.ItemState
                                LastModified                 = $cam.LastModified
                                Id                           = $cam.Id
                                IsStarted                    = $state.Status.Started
                                IsMotionDetected             = $state.Status.Motion
                                IsRecording                  = $state.Status.Recording
                                IsInOverflow                 = $state.Status.ErrorOverflow
                                IsInDbRepair                 = $state.Status.DbRepairInProgress
                                ErrorWritingGOP              = $state.Status.ErrorWritingGop
                                ErrorNotLicensed             = $state.Status.ErrorNotLicensed
                                ErrorNoConnection            = $state.Status.ErrorNoConnection
                                StatusTime                   = $state.Status.Time
                                GpsCoordinates               = $cam.GisPoint | ConvertFrom-GisPoint

                                HardwareName                 = $hw.Name
                                HardwareId                   = $hw.Id
                                Model                        = $hw.Model
                                Address                      = $hw.Address
                                Username                     = $hw.UserName
                                Password                     = if ($cache.Passwords.ContainsKey([guid]$hw.Id)) { $cache.Passwords[[guid]$hw.Id] } else { 'NotIncluded' }
                                HTTPSEnabled                 = $hwSettings.HTTPSEnabled -eq 'yes'
                                MAC                          = $hwSettings.MacAddress
                                Firmware                     = $hwSettings.FirmwareVersion

                                DriverFamily                 = $driver.GroupName
                                Driver                       = $driver.Name
                                DriverNumber                 = $driver.Number
                                DriverVersion                = $driver.DriverVersion
                                DriverRevision               = $driver.DriverRevision

                                RecorderName                 = $rec.Name
                                RecorderUri                  = $rec.ActiveWebServerUri, $rec.WebServerUri | Where-Object { ![string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1
                                RecorderId                   = $rec.Id

                                LiveStream                   = $liveStreamSettings.Name
                                LiveStreamDescription        = $liveStreamSettings.DisplayName
                                LiveStreamMode               = $liveStreamSettings.LiveMode
                                ConfiguredLiveResolution     = $liveStreamSettings.Settings.Resolution, $liveStreamSettings.Settings.StreamProperty | Where-Object { ![string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1
                                ConfiguredLiveCodec          = $liveStreamSettings.Settings.Codec
                                ConfiguredLiveFPS            = $liveStreamSettings.Settings.FPS, $liveStreamSettings.Settings.FrameRate | Where-Object { ![string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1
                                CurrentLiveResolution        = if ($null -eq $liveStreamStats) { 'Unavailable' } else { "{0}x{1}" -f $liveStreamStats.ImageResolution.Width, $liveStreamStats.ImageResolution.Height }
                                CurrentLiveCodec             = if ($null -eq $liveStreamStats) { 'Unavailable' } else { $liveStreamStats.VideoFormat }
                                CurrentLiveFPS               = if ($null -eq $liveStreamStats) { 'Unavailable' } else { $liveStreamStats.FPS -as [int] }
                                CurrentLiveBitrate           = if ($null -eq $liveStreamStats) { 'Unavailable' } else { (($liveStreamStats.BPS -as [int]) / 1MB).ToString('N1') }

                                RecordedStream               = $recordedStreamSettings.Name
                                RecordedStreamDescription    = $recordedStreamSettings.DisplayName
                                RecordedStreamMode           = $recordedStreamSettings.LiveMode
                                ConfiguredRecordedResolution = $recordedStreamSettings.Settings.Resolution, $recordedStreamSettings.Settings.StreamProperty | Where-Object { ![string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1
                                ConfiguredRecordedCodec      = $recordedStreamSettings.Settings.Codec
                                ConfiguredRecordedFPS        = $recordedStreamSettings.Settings.FPS, $recordedStreamSettings.Settings.FrameRate | Where-Object { ![string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1
                                CurrentRecordedResolution    = if ($null -eq $recordedStreamStats) { 'Unavailable' } else { "{0}x{1}" -f $recordedStreamStats.ImageResolution.Width, $recordedStreamStats.ImageResolution.Height }
                                CurrentRecordedCodec         = if ($null -eq $recordedStreamStats) { 'Unavailable' } else { $recordedStreamStats.VideoFormat }
                                CurrentRecordedFPS           = if ($null -eq $recordedStreamStats) { 'Unavailable' } else { $recordedStreamStats.FPS -as [int] }
                                CurrentRecordedBitrate       = if ($null -eq $recordedStreamStats) { 'Unavailable' } else { (($recordedStreamStats.BPS -as [int]) / 1MB).ToString('N1') }

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

                                RecordingStorageName         = $storage.Name
                                RecordingPath                = [io.path]::Combine($storage.DiskPath, $storage.Id)
                                ExpectedRetentionDays        = ($storage | Get-VmsStorageRetention).TotalDays
                                PercentRecordedOneWeek       = if ($IncludeRecordingStats) { $cache.RecordingStats[$id].PercentRecorded -as [double] } else { 'NotIncluded' }

                                MediaDatabaseBegin           = if ($null -eq $cache.PlaybackInfo[$id].Begin) { if ($IncludeRetentionInfo) { 'Unavailable' } else { 'NotIncluded' } } else { $cache.PlaybackInfo[$id].Begin }
                                MediaDatabaseEnd             = if ($null -eq $cache.PlaybackInfo[$id].End) { if ($IncludeRetentionInfo) { 'Unavailable' } else { 'NotIncluded' } } else { $cache.PlaybackInfo[$id].End }
                                UsedSpaceInGB                = if ($null -eq $state.UsedSpaceInBytes) { 'Unavailable' } else { ($state.UsedSpaceInBytes / 1GB).ToString('N2') }

                            }
                            if ($IncludeRetentionInfo) {
                                $obj.ActualRetentionDays     = ($cache.PlaybackInfo[$id].End - $cache.PlaybackInfo[$id].Begin).TotalDays
                                $obj.MeetsRetentionPolicy    = $obj.ActualRetentionDays -gt $obj.ExpectedRetentionDays
                                $obj.MediaDatabaseBegin      = $cache.PlaybackInfo[$id].Begin
                                $obj.MediaDatabaseEnd        = $cache.PlaybackInfo[$id].End
                            }

                            $obj.MotionEnabled = $motion.Enabled
                            $obj.MotionKeyframesOnly = $motion.KeyframesOnly
                            $obj.MotionProcessTime = $motion.ProcessTime
                            $obj.MotionManualSensitivityEnabled = $motion.ManualSensitivityEnabled
                            $obj.MotionManualSensitivity = [int]($motion.ManualSensitivity / 3)
                            $obj.MotionThreshold = $motion.Threshold
                            $obj.MotionMetadataEnabled = $motion.GenerateMotionMetadata
                            $obj.MotionExcludeRegions = $motion.UseExcludeRegions
                            $obj.MotionHardwareAccelerationMode = $motion.HardwareAccelerationMode

                            $obj.PrivacyMaskEnabled = ($cam.PrivacyProtectionFolder.PrivacyProtections | Select-Object -First 1).Enabled -eq $true

                            if ($IncludeSnapshots) {
                                $obj.Snapshot = $cache.Snapshots[$id]
                            }
                            Write-Output ([pscustomobject]$obj)
                        }
                    }
                    catch {
                        Write-Error $_
                    }
                }
            }
        }
        finally {
            if ($jobRunner) {
                $jobRunner.Dispose()
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsCameraReport -ParameterName RecordingServer -ScriptBlock {
    $values = (Get-VmsRecordingServer).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Add-VmsArchiveStorage {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.ArchiveStorage])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Storage]
        $Storage,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter()]
        [string]
        $Description,

        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [string]
        $Path,

        [Parameter()]
        [ValidateScript({
            if ($_ -lt [timespan]::FromMinutes(60)) {
                throw "Retention must be greater than or equal to one hour"
            }
            if ($_ -gt [timespan]::FromMinutes([int]::MaxValue)) {
                throw "Retention must be less than or equal to $([int]::MaxValue) minutes."
            }
            $true
        })]
        [timespan]
        $Retention,

        [Parameter(Mandatory)]
        [ValidateRange(1, [int]::MaxValue)]
        [int]
        $MaximumSizeMB,

        [Parameter()]
        [switch]
        $ReduceFramerate,

        [Parameter()]
        [ValidateRange(0.00028, 100)]
        [double]
        $TargetFramerate = 5
    )

    process {
        $archiveFolder = $Storage.ArchiveStorageFolder
        if ($PSCmdlet.ShouldProcess("Recording storage '$($Storage.Name)'", "Add new archive storage named '$($Name)' with retention of $($Retention.TotalHours) hours and a maximum size of $($MaximumSizeMB) MB")) {
            try {
                $taskInfo = $archiveFolder.AddArchiveStorage($Name, $Description, $Path, $TargetFrameRate, $Retention.TotalMinutes, $MaximumSizeMB)
                if ($taskInfo.State -ne [videoos.platform.configurationitems.stateenum]::Success) {
                    Write-Error -Message $taskInfo.ErrorText
                    return
                }

                $archive = [VideoOS.Platform.ConfigurationItems.ArchiveStorage]::new((Get-VmsManagementServer).ServerId, $taskInfo.Path)

                if ($ReduceFramerate) {
                    $invokeInfo = $archive.SetFramerateReductionArchiveStorage()
                    $invokeInfo.SetProperty('FramerateReductionEnabled', 'True')
                    [void]$invokeInfo.ExecuteDefault()
                }

                $storage.ClearChildrenCache()
                Write-Output $archive
            }
            catch {
                Write-Error $_
                return
            }
        }
    }
}
function Add-VmsStorage {
    [CmdletBinding(DefaultParameterSetName = 'WithoutEncryption', SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.Storage])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'WithoutEncryption')]
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'WithEncryption')]
        [RecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.RecordingServer]
        $RecordingServer,

        [Parameter(Mandatory, ParameterSetName = 'WithoutEncryption')]
        [Parameter(Mandatory, ParameterSetName = 'WithEncryption')]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter(ParameterSetName = 'WithoutEncryption')]
        [Parameter(ParameterSetName = 'WithEncryption')]
        [string]
        $Description,

        [Parameter(Mandatory, ParameterSetName = 'WithoutEncryption')]
        [Parameter(Mandatory, ParameterSetName = 'WithEncryption')]
        [ValidateNotNullOrEmpty()]
        [string]
        $Path,

        [Parameter(ParameterSetName = 'WithoutEncryption')]
        [Parameter(ParameterSetName = 'WithEncryption')]
        [ValidateScript({
            if ($_ -lt [timespan]::FromMinutes(60)) {
                throw "Retention must be greater than or equal to one hour"
            }
            if ($_ -gt [timespan]::FromMinutes([int]::MaxValue)) {
                throw "Retention must be less than or equal to $([int]::MaxValue) minutes."
            }
            $true
        })]
        [timespan]
        $Retention,

        [Parameter(Mandatory, ParameterSetName = 'WithoutEncryption')]
        [Parameter(Mandatory, ParameterSetName = 'WithEncryption')]
        [ValidateRange(1, [int]::MaxValue)]
        [int]
        $MaximumSizeMB,

        [Parameter(ParameterSetName = 'WithoutEncryption')]
        [Parameter(ParameterSetName = 'WithEncryption')]
        [switch]
        $Default,

        [Parameter(ParameterSetName = 'WithoutEncryption')]
        [Parameter(ParameterSetName = 'WithEncryption')]
        [switch]
        $EnableSigning,

        [Parameter(Mandatory, ParameterSetName = 'WithEncryption')]
        [ValidateSet('Light', 'Strong', IgnoreCase = $false)]
        [string]
        $EncryptionMethod,

        [Parameter(Mandatory, ParameterSetName = 'WithEncryption')]
        [securestring]
        $Password
    )

    process {
        $storageFolder = $RecordingServer.StorageFolder
        if ($PSCmdlet.ShouldProcess("Recording Server '$($RecordingServer.Name)' at $($RecordingServer.HostName)", "Add new storage named '$($Name)' with retention of $($Retention.TotalHours) hours and a maximum size of $($MaximumSizeMB) MB")) {
            try {
                $taskInfo = $storageFolder.AddStorage($Name, $Description, $Path, $EnableSigning, $Retention.TotalMinutes, $MaximumSizeMB)
                if ($taskInfo.State -ne [videoos.platform.configurationitems.stateenum]::Success) {
                    Write-Error -Message $taskInfo.ErrorText
                    return
                }
            }
            catch {
                Write-Error $_
                return
            }

            $storage = [VideoOS.Platform.ConfigurationItems.Storage]::new((Get-VmsManagementServer).ServerId, $taskInfo.Path)
        }

        if ($PSCmdlet.ParameterSetName -eq 'WithEncryption' -and $PSCmdlet.ShouldProcess("Recording Storage '$Name'", "Enable '$EncryptionMethod' Encryption")) {
            try {
                $invokeResult = $storage.EnableEncryption($Password, $EncryptionMethod)
                if ($invokeResult.State -ne [videoos.platform.configurationitems.stateenum]::Success) {
                    throw $invokeResult.ErrorText
                }

                $storage = [VideoOS.Platform.ConfigurationItems.Storage]::new((Get-VmsManagementServer).ServerId, $taskInfo.Path)
            }
            catch {
                [void]$storageFolder.RemoveStorage($taskInfo.Path)
                Write-Error $_
                return
            }
        }

        if ($Default -and $PSCmdlet.ShouldProcess("Recording Storage '$Name'", "Set as default storage configuration")) {
            try {
                $invokeResult = $storage.SetStorageAsDefault()
                if ($invokeResult.State -ne [videoos.platform.configurationitems.stateenum]::Success) {
                    throw $invokeResult.ErrorText
                }

                $storage = [VideoOS.Platform.ConfigurationItems.Storage]::new((Get-VmsManagementServer).ServerId, $taskInfo.Path)
            }
            catch {
                [void]$storageFolder.RemoveStorage($taskInfo.Path)
                Write-Error $_
                return
            }
        }

        if (!$PSBoundParameters.ContainsKey('WhatIf')) {
            Write-Output $storage
        }
    }
}

Register-ArgumentCompleter -CommandName Set-VmsStorage -ParameterName RecordingServer -ScriptBlock {
    $values = (Get-VmsRecordingServer).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsArchiveStorage {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.ArchiveStorage])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Storage]
        $Storage,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [SupportsWildcards()]
        [string]
        $Name = '*'
    )

    process {
        $storagesMatched = 0
        $Storage.ArchiveStorageFolder.ArchiveStorages | ForEach-Object {
            if ($_.Name -like $Name) {
                $storagesMatched++
                Write-Output $_
            }
        }

        if ($storagesMatched -eq 0 -and -not [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($Name)) {
            Write-Error "No recording storages found matching the name '$Name'"
        }
    }
}
function Get-VmsStorage {
    [CmdletBinding(DefaultParameterSetName = 'FromName')]
    [OutputType([VideoOS.Platform.ConfigurationItems.Storage])]
    param (
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'FromName')]
        [RecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.RecordingServer[]]
        $RecordingServer,

        [Parameter(ParameterSetName = 'FromName')]
        [ValidateNotNullOrEmpty()]
        [SupportsWildcards()]
        [string]
        $Name = '*',

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'FromPath')]
        [ValidateScript({
            if ($_ -match 'Storage\[.{36}\]') {
                $true
            }
            else {
                throw "Invalid storage item path. Expected format: Storage[$([guid]::NewGuid())]"
            }
        })]
        [Alias('RecordingStorage', 'Path')]
        [string]
        $ItemPath
    )

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'FromName' {
                if ($null -eq $RecordingServer -or $RecordingServer.Count -eq 0) {
                    $RecordingServer = Get-VmsRecordingServer
                }
                $storagesMatched = 0
                $RecordingServer.StorageFolder.Storages | ForEach-Object {
                    if ($_.Name -like $Name) {
                        $storagesMatched++
                        Write-Output $_
                    }
                }

                if ($storagesMatched -eq 0 -and -not [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($Name)) {
                    Write-Error "No recording storages found matching the name '$Name'"
                }
            }
            'FromPath' {
                [VideoOS.Platform.ConfigurationItems.Storage]::new((Get-VmsManagementServer).ServerId, $ItemPath)
            }
            Default {
                throw "ParameterSetName $($PSCmdlet.ParameterSetName) not implemented"
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsStorage -ParameterName RecordingServer -ScriptBlock {
    $values = (Get-VmsRecordingServer).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Remove-VmsArchiveStorage {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'ByName')]
        [VideoOS.Platform.ConfigurationItems.Storage]
        $Storage,

        [Parameter(Mandatory, ParameterSetName = 'ByName')]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'ByStorage')]
        [VideoOS.Platform.ConfigurationItems.ArchiveStorage]
        $ArchiveStorage
    )

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'ByName' {
                foreach ($archiveStorage in $Storage | Get-VmsArchiveStorage -Name $Name) {
                    $archiveStorage | Remove-VmsArchiveStorage
                }
            }

            'ByStorage' {
                $recorder = [VideoOS.Platform.ConfigurationItems.RecordingServer]::new((Get-VmsManagementServer).ServerId, $Storage.ParentItemPath)
                $storage = [VideoOS.Platform.ConfigurationItems.Storage]::new((Get-VmsManagementServer).ServerId, $ArchiveStorage.ParentItemPath)
                if ($PSCmdlet.ShouldProcess("Recording server $($recorder.Name)", "Delete archive $($ArchiveStorage.Name) from $($storage.Name)")) {
                    $folder = [VideoOS.Platform.ConfigurationItems.ArchiveStorageFolder]::new((Get-VmsManagementServer).ServerId, $ArchiveStorage.ParentPath)
                    [void]$folder.RemoveArchiveStorage($ArchiveStorage.Path)
                }
            }
            Default {
                throw 'Unknown parameter set'
            }
        }
    }
}
function Remove-VmsStorage {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'ByName')]
        [RecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.RecordingServer]
        $RecordingServer,

        [Parameter(Mandatory, ParameterSetName = 'ByName')]
        [ValidateNotNullOrEmpty()]
        [string]
        $Name,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'ByStorage')]
        [VideoOS.Platform.ConfigurationItems.Storage]
        $Storage
    )

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'ByName' {
                foreach ($vmsStorage in $RecordingServer | Get-VmsStorage -Name $Name) {
                    $vmsStorage | Remove-VmsStorage
                }
            }

            'ByStorage' {
                $recorder = [VideoOS.Platform.ConfigurationItems.RecordingServer]::new((Get-VmsManagementServer).ServerId, $Storage.ParentItemPath)
                if ($PSCmdlet.ShouldProcess("Recording server $($recorder.Name)", "Delete $($Storage.Name) and all archives")) {
                    $folder = [VideoOS.Platform.ConfigurationItems.StorageFolder]::new((Get-VmsManagementServer).ServerId, $Storage.ParentPath)
                    [void]$folder.RemoveStorage($Storage.Path)
                }
            }
            Default {
                throw 'Unknown parameter set'
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Remove-VmsStorage -ParameterName RecordingServer -ScriptBlock {
    $values = (Get-VmsRecordingServer).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function ConvertFrom-ConfigurationApiProperties {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.ConfigurationApiProperties]
        $Properties,

        [Parameter()]
        [switch]
        $UseDisplayNames
    )

    process {
        $languageId = (Get-Culture).Name
        $result = @{}
        foreach ($key in $Properties.Keys) {
            if ($key -notmatch '^.+/(?<Key>.+)/(?:[0-9A-F\-]{36})$') {
                Write-Warning "Failed to parse property with key name '$key'"
                continue
            }
            $propertyInfo = $Properties.GetValueTypeInfoCollection($key)
            $propertyValue = $Properties.GetValue($key)

            if ($UseDisplayNames) {
                $valueTypeInfo = $propertyInfo | Where-Object Value -eq $propertyValue
                $displayName = $valueTypeInfo.Name
                if ($propertyInfo.Count -gt 0 -and $displayName -and $displayName -notin @('true', 'false', 'MinValue', 'MaxValue', 'StepValue')) {
                    if ($valueTypeInfo.TranslationId -and $languageId -and $languageId -ne 'en-US') {
                        $translatedName = (Get-Translations -LanguageId $languageId).($valueTypeInfo.TranslationId)
                        if (![string]::IsNullOrWhiteSpace($translatedName)) {
                            $displayName = $translatedName
                        }
                    }
                    $result[$Matches.Key] = $displayName
                }
                else {
                    $result[$Matches.Key] = $propertyValue
                }
            }
            else {
                $result[$Matches.Key] = $propertyValue
            }
        }

        Write-Output $result
    }
}
function ConvertFrom-GisPoint {
    [CmdletBinding()]
    [OutputType([system.device.location.geocoordinate])]
    param (
        # Specifies the GisPoint value to convert to a GeoCoordinate. Milestone stores GisPoint data in the format "POINT ([longitude] [latitude])" or "POINT EMPTY".
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ValueFromPipeline)]
        [string]
        $GisPoint
    )

    process {
        if ($GisPoint -eq 'POINT EMPTY') {
            Write-Output ([system.device.location.geocoordinate]::Unknown)
        }
        else {
            $temp = $GisPoint.Substring(7, $GisPoint.Length - 8)
            $long, $lat, $null = $temp -split ' '
            Write-Output ([system.device.location.geocoordinate]::new($lat, $long))
        }
    }
}
function ConvertFrom-Snapshot {
    [CmdletBinding()]
    [OutputType([system.drawing.image])]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('Bytes')]
        [byte[]]
        $Content
    )

    process {
        if ($null -eq $Content -or $Content.Length -eq 0) {
            return $null
        }
        $ms = [io.memorystream]::new($Content)
        Write-Output ([system.drawing.image]::FromStream($ms))
    }
}
function ConvertTo-GisPoint {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'FromGeoCoordinate')]
        [system.device.location.geocoordinate]
        $Coordinate,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'FromValues')]
        [double]
        $Latitude,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'FromValues')]
        [double]
        $Longitude,

        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'FromValues')]
        [double]
        $Altitude = [double]::NaN,

        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'FromString')]
        [string]
        $Coordinates
    )

    process {

        switch ($PsCmdlet.ParameterSetName) {
            'FromValues' {
                # do nothing?
                break
            }

            'FromGeoCoordinate' {
                $Latitude = $Coordinate.Latitude
                $Longitude = $Coordinate.Longitude
                $Altitude = $Coordinate.Altitude
                break
            }

            'FromString' {
                $values = $Coordinates -split ',' | Foreach-Object {
                    [double]$_.Trim()
                }
                if ($values.Count -lt 2 -or $values.Count -gt 3) {
                    Write-Error "Failed to parse coordinates into latitude, longitude and optional altitude."
                    return
                }
                $Latitude = $values[0]
                $Longitude = $values[1]
                if ($values.Count -gt 2) {
                    $Altitude = $values[2]
                }
                break
            }
        }

        if ([double]::IsNan($Altitude)) {
            Write-Output ('POINT ({0} {1})' -f $Longitude, $Latitude)
        }
        else {
            Write-Output ('POINT ({0} {1} {2})' -f $Longitude, $Latitude, $Altitude)
        }
    }
}
function Get-BankTable {
    [CmdletBinding()]
    param (
        [Parameter()]
        [string]
        $Path,
        [Parameter()]
        [string[]]
        $DeviceId,
        [Parameter()]
        [DateTime]
        $StartTime = [DateTime]::MinValue,
        [Parameter()]
        [DateTime]
        $EndTime = [DateTime]::MaxValue.AddHours(-1)

    )

    process {
        $di = [IO.DirectoryInfo]$Path
        foreach ($table in $di.EnumerateDirectories()) {
            if ($table.Name -match "^(?<id>[0-9a-fA-F\-]{36})(_(?<tag>\w+)_(?<endTime>\d\d\d\d-\d\d-\d\d_\d\d-\d\d-\d\d).*)?") {
                $tableTimestamp = if ($null -eq $Matches["endTime"]) { (Get-Date).ToString("yyyy-MM-dd_HH-mm-ss") } else { $Matches["endTime"] }
                $timestamp = [DateTime]::ParseExact($tableTimestamp, "yyyy-MM-dd_HH-mm-ss", [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeLocal)
                if ($timestamp -lt $StartTime -or $timestamp -gt $EndTime.AddHours(1)) {
                    # Timestamp of table is outside the requested timespan
                    continue
                }
                if ($null -ne $DeviceId -and [cultureinfo]::InvariantCulture.CompareInfo.IndexOf($DeviceId, $Matches["id"], [System.Globalization.CompareOptions]::IgnoreCase) -eq -1) {
                    # Device ID for table is not requested
                    continue
                }
                [pscustomobject]@{
                    DeviceId = [Guid]$Matches["id"]
                    EndTime = $timestamp
                    Tag = $Matches["tag"]
                    IsLiveTable = $null -eq $Matches["endTime"]
                    Path = $table.FullName
                }
            }
        }
    }
}
function Get-ConfigurationItemProperty {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]
        [ValidateNotNullOrEmpty()]
        $InputObject,
        [Parameter(Mandatory)]
        [string]
        [ValidateNotNullOrEmpty()]
        $Key
    )

    process {
        $property = $InputObject.Properties | Where-Object Key -eq $Key
        if ($null -eq $property) {
            Write-Error -Message "Key '$Key' not found on configuration item $($InputObject.Path)" -TargetObject $InputObject -Category InvalidArgument
            return
        }
        $property.Value
    }
}
function Get-StreamProperties {
    [CmdletBinding()]
    [OutputType([VideoOS.ConfigurationApi.ClientService.Property[]])]
    param (
        # Specifies the camera to retrieve stream properties for
        [Parameter(ValueFromPipeline, Mandatory, ParameterSetName = 'ByName')]
        [Parameter(ValueFromPipeline, Mandatory, ParameterSetName = 'ByNumber')]
        [VideoOS.Platform.ConfigurationItems.Camera]
        $Camera,

        # Specifies a StreamUsageChildItem from Get-Stream
        [Parameter(ParameterSetName = 'ByName')]
        [ValidateNotNullOrEmpty()]
        [string]
        $StreamName,

        # Specifies the stream number starting from 0. For example, "Video stream 1" is usually in the 0'th position in the StreamChildItems collection.
        [Parameter(ParameterSetName = 'ByNumber')]
        [ValidateRange(0, [int]::MaxValue)]
        [int]
        $StreamNumber
    )

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'ByName' {
                $stream = (Get-ConfigurationItem -Path "DeviceDriverSettings[$($Camera.Id)]").Children | Where-Object { $_.ItemType -eq 'Stream' -and $_.DisplayName -like $StreamName }
                if ($null -eq $stream -and ![system.management.automation.wildcardpattern]::ContainsWildcardCharacters($StreamName)) {
                    Write-Error "No streams found on $($Camera.Name) matching the name '$StreamName'"
                    return
                }
                foreach ($obj in $stream) {
                    Write-Output $obj.Properties
                }
            }
            'ByNumber' {
                $streams = (Get-ConfigurationItem -Path "DeviceDriverSettings[$($Camera.Id)]").Children | Where-Object { $_.ItemType -eq 'Stream' }
                if ($StreamNumber -lt $streams.Count) {
                    Write-Output ($streams[$StreamNumber].Properties)
                }
                else {
                    Write-Error "There are $($streams.Count) streams available on the camera and stream number $StreamNumber does not exist. Remember to index the streams from zero."
                }
            }
            Default {}
        }
    }
}
function Get-ValueDisplayName {
    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Mandatory, ParameterSetName = 'ConfigurationApi')]
        [VideoOS.ConfigurationApi.ClientService.Property[]]
        $PropertyList,

        [Parameter(Mandatory, ParameterSetName = 'StrongTypes')]
        [VideoOS.Platform.ConfigurationItems.ConfigurationApiProperties]
        $Properties,

        [Parameter(Mandatory, ParameterSetName = 'ConfigurationApi')]
        [Parameter(Mandatory, ParameterSetName = 'StrongTypes')]
        [string[]]
        $PropertyName,

        [Parameter()]
        [string]
        $DefaultValue = 'NotAvailable'
    )

    process {
        $value = $DefaultValue
        if ($null -eq $PropertyList -or $PropertyList.Count -eq 0) {
            return $value
        }

        $selectedProperty = $null
        foreach ($property in $PropertyList) {
            foreach ($name in $PropertyName) {
                if ($property.Key -like "*/$name/*") {
                    $selectedProperty = $property
                    break
                }
            }
            if ($null -ne $selectedProperty) { break }
        }
        if ($null -ne $selectedProperty) {
            $value = $selectedProperty.Value
            if ($selectedProperty.ValueType -eq 'Enum') {
                $displayName = ($selectedProperty.ValueTypeInfos | Where-Object Value -eq $selectedProperty.Value).Name
                if (![string]::IsNullOrWhiteSpace($displayName)) {
                    $value = $displayName
                }
            }
        }
        Write-Output $value
    }
}
function Install-StableFPS {
    [CmdletBinding()]
    param (
        [Parameter()]
        [string]
        $Source = "C:\Program Files\Milestone\MIPSDK\Tools\StableFPS",
        [Parameter()]
        [int]
        [ValidateRange(1, 200)]
        $Cameras = 32,
        [Parameter()]
        [int]
        [ValidateRange(1, 5)]
        $Streams = 1,
        [Parameter()]
        [string]
        $DevicePackPath
    )

    begin {
        Assert-IsAdmin
        if (!(Test-Path (Join-Path $Source "StableFPS_DATA"))) {
            throw "Path not found: $((Join-Path $Source "StableFPS_DATA"))"
        }
        if (!(Test-Path (Join-Path $Source "vLatest"))) {
            throw "Path not found: $((Join-Path $Source "vLatest"))"
        }
    }

    process {
        $serviceStopped = $false
        try {
            $dpPath = if ([string]::IsNullOrWhiteSpace($DevicePackPath)) { (Get-RecorderConfig).DevicePackPath } else { $DevicePackPath }
            if (!(Test-Path $dpPath)) {
                throw "DevicePackPath not valid"
            }
            if ([string]::IsNullOrWhiteSpace($DevicePackPath)) {
                $service = Get-Service "Milestone XProtect Recording Server"
                if ($service.Status -eq [System.ServiceProcess.ServiceControllerStatus]::Running) {
                    $service | Stop-Service -Force
                    $serviceStopped = $true
                }
            }

            $srcData = Join-Path $Source "StableFPS_Data"
            $srcDriver = Join-Path $Source "vLatest"
            Copy-Item $srcData -Destination $dpPath -Container -Recurse -Force
            Copy-Item "$srcDriver\*" -Destination $dpPath -Recurse -Force

            $tempXml = Join-Path $dpPath "resources\StableFPS_TEMP.xml"
            $newXml = Join-Path $dpPath "resources\StableFPS.xml"
            $content = Get-Content $tempXml -Raw
            $content = $content.Replace("{CAM_NUM_REQUESTED}", $Cameras)
            $content = $content.Replace("{STREAM_NUM_REQUESTED}", $Streams)
            $content | Set-Content $newXml
            Remove-Item $tempXml
        }
        catch {
            throw
        }
        finally {
            if ($serviceStopped -and $null -ne $service) {
                $service.Refresh()
                $service.Start()
            }
        }
    }
}
function Invoke-ServerConfigurator {
    [CmdletBinding()]
    param(
        # Enable encryption for the CertificateGroup specified
        [Parameter(ParameterSetName = 'EnableEncryption', Mandatory)]
        [switch]
        $EnableEncryption,

        # Disable encryption for the CertificateGroup specified
        [Parameter(ParameterSetName = 'DisableEncryption', Mandatory)]
        [switch]
        $DisableEncryption,

        # Specifies the CertificateGroup [guid] identifying which component for which encryption
        # should be enabled or disabled
        [Parameter(ParameterSetName = 'EnableEncryption', Mandatory)]
        [Parameter(ParameterSetName = 'DisableEncryption', Mandatory)]
        [guid]
        $CertificateGroup,

        # Specifies the thumbprint of the certificate to be used to encrypt communications with the
        # component designated by the CertificateGroup id.
        [Parameter(ParameterSetName = 'EnableEncryption', Mandatory)]
        [string]
        $Thumbprint,

        # List the available certificate groups on the local machine. Output will be a [hashtable]
        # where the keys are the certificate group names (which may contain spaces) and the values
        # are the associated [guid] id's.
        [Parameter(ParameterSetName = 'ListCertificateGroups')]
        [switch]
        $ListCertificateGroups,

        # Register all local components with the optionally specified AuthAddress. If no
        # AuthAddress is provided, the last-known address will be used.
        [Parameter(ParameterSetName = 'Register', Mandatory)]
        [switch]
        $Register,

        # Specifies the address of the Authorization Server which is usually the Management Server
        # address. A [uri] value is expected, but only the URI host value will be used. The scheme
        # and port will be inferred based on whether encryption is enabled/disabled and is fixed to
        # port 80/443 as this is how Server Configurator is currently designed.
        [Parameter(ParameterSetName = 'Register')]
        [uri]
        $AuthAddress,

        # Specifies the path to the Server Configurator utility. Omit this path and the path will
        # be discovered using Get-RecorderConfig or Get-ManagementServerConfig by locating the
        # installation path of the Management Server or Recording Server and assuming the Server
        # Configurator is located in the same path.
        [Parameter()]
        [string]
        $Path,

        # Specifies that the standard output from the Server Configurator utility should be written
        # after the operation is completed. The output will include the following properties:
        # - StandardOutput
        # - StandardError
        # - ExitCode
        [Parameter(ParameterSetName = 'EnableEncryption')]
        [Parameter(ParameterSetName = 'DisableEncryption')]
        [Parameter(ParameterSetName = 'Register')]
        [switch]
        $PassThru
    )

    process {
        $exePath = $Path
        if ([string]::IsNullOrWhiteSpace($exePath)) {
            # Find ServerConfigurator.exe by locating either the Management Server or Recording Server installation path
            $configurationInfo = try {
                Get-ManagementServerConfig
            }
            catch {
                try {
                    Get-RecorderConfig
                }
                catch {
                    $null
                }
            }
            if ($null -eq $configurationInfo) {
                Write-Error "Could not find a Management Server or Recording Server installation"
                return
            }
            $fileInfo = [io.fileinfo]::new($configurationInfo.InstallationPath)
            $exePath = Join-Path $fileInfo.Directory.Parent.FullName "Server Configurator\serverconfigurator.exe"
            if (-not (Test-Path $exePath)) {
                Write-Error "Expected to find Server Configurator at '$exePath' but failed."
                return
            }
        }


        # Ensure version is 20.3 (2020 R3) or newer
        $fileInfo = [io.fileinfo]::new($exePath)
        if ($fileInfo.VersionInfo.FileVersion -lt [version]"20.3") {
            Write-Error "Invoke-ServerConfigurator requires Milestone version 2020 R3 or newer as this is when command-line options were introduced. Found Server Configurator version $($fileInfo.VersionInfo.FileVersion)"
            return
        }

        $exitCode = @{
            0 = 'Success'
            -1 = 'Unknown error'
            -2 = 'Invalid arguments'
            -3 = 'Invalid argument value'
            -4 = 'Another instance is running'
        }

        # Get Certificate Group list for either display to user or verification
        $output = Get-ProcessOutput -FilePath $exePath -ArgumentList /listcertificategroups
        if ($output.ExitCode -ne 0) {
            Write-Error "Server Configurator exited with code $($output.ExitCode). $($exitCode.($output.ExitCode))."
            Write-Error $output.StandardOutput
            return
        }
        Write-Information $output.StandardOutput
        $groups = @{}
        foreach ($line in $output.StandardOutput -split ([environment]::NewLine)) {
            if ($line -match "Found '(?<groupName>.+)' group with ID = (?<groupId>.{36})") {
                $groups.$($Matches.groupName) = [guid]::Parse($Matches.groupId)
            }
        }


        switch ($PSCmdlet.ParameterSetName) {
            'EnableEncryption' {
                if ($groups.Values -notcontains $CertificateGroup) {
                    Write-Error "CertificateGroup value '$CertificateGroup' not found. Use the ListCertificateGroups switch to discover valid CertificateGroup values"
                    return
                }

                $enableArgs = @('/enableencryption', "/certificategroup=$CertificateGroup", "/thumbprint=$Thumbprint", '/quiet')
                $output = Get-ProcessOutput -FilePath $exePath -ArgumentList $enableArgs
                if ($output.ExitCode -ne 0) {
                    Write-Error "EnableEncryption failed. Server Configurator exited with code $($output.ExitCode). $($exitCode.($output.ExitCode))."
                    Write-Error $output.StandardOutput
                }
            }

            'DisableEncryption' {
                if ($groups.Values -notcontains $CertificateGroup) {
                    Write-Error "CertificateGroup value '$CertificateGroup' not found. Use the ListCertificateGroups switch to discover valid CertificateGroup values"
                    return
                }
                $disableArgs = @('/disableencryption', "/certificategroup=$CertificateGroup", '/quiet')
                $output = Get-ProcessOutput -FilePath $exePath -ArgumentList $disableArgs
                if ($output.ExitCode -ne 0) {
                    Write-Error "EnableEncryption failed. Server Configurator exited with code $($output.ExitCode). $($exitCode.($output.ExitCode))."
                    Write-Error $output.StandardOutput
                }
            }

            'ListCertificateGroups' {
                Write-Output $groups
                return
            }

            'Register' {
                $registerArgs = @('/register', '/quiet')
                if ($PSCmdlet.MyInvocation.BoundParameters -contains 'AuthAddress') {
                    $registerArgs += $AuthAddress.ToString()
                }
                $output = Get-ProcessOutput -FilePath $exePath -ArgumentList $registerArgs
                if ($output.ExitCode -ne 0) {
                    Write-Error "Registration failed. Server Configurator exited with code $($output.ExitCode). $($exitCode.($output.ExitCode))."
                    Write-Error $output.StandardOutput
                }

            }

            Default {
            }
        }

        Write-Information $output.StandardOutput
        if ($PassThru) {
            Write-Output $output
        }
    }
}
function Resize-Image {
    [CmdletBinding()]
    [OutputType([System.Drawing.Image])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [System.Drawing.Image]
        $Image,

        [Parameter(Mandatory)]
        [int]
        $Height,

        [Parameter()]
        [long]
        $Quality = 95,

        [Parameter()]
        [ValidateSet('BMP', 'JPEG', 'GIF', 'TIFF', 'PNG')]
        [string]
        $OutputFormat,

        [Parameter()]
        [switch]
        $DisposeSource
    )

    process {
        if ($null -eq $Image -or $Image.Width -le 0 -or $Image.Height -le 0) {
            Write-Error 'Cannot resize an invalid image object.'
            return
        }

        [int]$width = $image.Width / $image.Height * $Height
        $bmp = [system.drawing.bitmap]::new($width, $Height)
        $graphics = [system.drawing.graphics]::FromImage($bmp)
        $graphics.InterpolationMode = [system.drawing.drawing2d.interpolationmode]::HighQualityBicubic
        $graphics.DrawImage($Image, 0, 0, $width, $Height)
        $graphics.Dispose()

        try {
            $formatId = if ([string]::IsNullOrWhiteSpace($OutputFormat)) {
                    $Image.RawFormat.Guid
                }
                else {
                    ([system.drawing.imaging.imagecodecinfo]::GetImageEncoders() | Where-Object FormatDescription -eq $OutputFormat).FormatID
                }
            $encoder = [system.drawing.imaging.imagecodecinfo]::GetImageEncoders() | Where-Object FormatID -eq $formatId
            $encoderParameters = [system.drawing.imaging.encoderparameters]::new(1)
            $qualityParameter = [system.drawing.imaging.encoderparameter]::new([system.drawing.imaging.encoder]::Quality, $Quality)
            $encoderParameters.Param[0] = $qualityParameter
            Write-Verbose "Saving resized image as $($encoder.FormatDescription) with $Quality% quality"
            $ms = [io.memorystream]::new()
            $bmp.Save($ms, $encoder, $encoderParameters)
            $resizedImage = [system.drawing.image]::FromStream($ms)
            Write-Output ($resizedImage)
        }
        finally {
            $qualityParameter.Dispose()
            $encoderParameters.Dispose()
            $bmp.Dispose()
            if ($DisposeSource) {
                $Image.Dispose()
            }
        }

    }
}
function Select-Camera {
    [CmdletBinding()]
    param(
        [Parameter()]
        [string]
        $Title = "Select Camera(s)",
        [Parameter()]
        [switch]
        $SingleSelect,
        [Parameter()]
        [switch]
        $AllowFolders,
        [Parameter()]
        [switch]
        $AllowServers,
        [Parameter()]
        [switch]
        $RemoveDuplicates,
        [Parameter()]
        [switch]
        $OutputAsItem
    )
    process {
        $items = Select-VideoOSItem -Title $Title -Kind ([VideoOS.Platform.Kind]::Camera) -AllowFolders:$AllowFolders -AllowServers:$AllowServers -SingleSelect:$SingleSelect -FlattenOutput
        $processed = @{}
        if ($RemoveDuplicates) {
            foreach ($item in $items) {
                if ($processed.ContainsKey($item.FQID.ObjectId)) {
                    continue
                }
                $processed.Add($item.FQID.ObjectId, $null)
                if ($OutputAsItem) {
                    Write-Output $item
                }
                else {
                    Get-VmsCamera -Id $item.FQID.ObjectId
                }
            }
        }
        else {
            if ($OutputAsItem) {
                Write-Output $items
            }
            else {
                Write-Output ($items | ForEach-Object { Get-VmsCamera -Id $_.FQID.ObjectId })
            }
        }
    }
}
function Select-VideoOSItem {
    [CmdletBinding()]
    param (
        [Parameter()]
        [string]
        $Title = "Select Item(s)",
        [Parameter()]
        [guid[]]
        $Kind,
        [Parameter()]
        [VideoOS.Platform.Admin.Category[]]
        $Category,
        [Parameter()]
        [switch]
        $SingleSelect,
        [Parameter()]
        [switch]
        $AllowFolders,
        [Parameter()]
        [switch]
        $AllowServers,
        [Parameter()]
        [switch]
        $KindUserSelectable,
        [Parameter()]
        [switch]
        $CategoryUserSelectable,
        [Parameter()]
        [switch]
        $FlattenOutput,
        [Parameter()]
        [switch]
        $HideGroupsTab,
        [Parameter()]
        [switch]
        $HideServerTab
    )

    process {
        $form = [MilestonePSTools.UI.CustomItemPickerForm]::new();
        $form.KindFilter = $Kind
        $form.CategoryFilter = $Category
        $form.AllowFolders = $AllowFolders
        $form.AllowServers = $AllowServers
        $form.KindUserSelectable = $KindUserSelectable
        $form.CategoryUserSelectable = $CategoryUserSelectable
        $form.SingleSelect = $SingleSelect
        $form.GroupTabVisable = -not $HideGroupsTab
        $form.ServerTabVisable = -not $HideServerTab
        $form.Icon = [System.Drawing.Icon]::FromHandle([VideoOS.Platform.UI.Util]::ImageList.Images[[VideoOS.Platform.UI.Util]::SDK_GeneralIx].GetHicon())
        $form.Text = $Title
        $form.TopMost = $true
        $form.StartPosition = [System.Windows.Forms.FormStartPosition]::CenterScreen
        $form.BringToFront()
        $form.Activate()

        if ($form.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
            if ($FlattenOutput) {
                Write-Output $form.ItemsSelectedFlattened
            }
            else {
                Write-Output $form.ItemsSelected
            }
        }
    }
}
function Set-ConfigurationItemProperty {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]
        [ValidateNotNullOrEmpty()]
        $InputObject,
        [Parameter(Mandatory)]
        [string]
        [ValidateNotNullOrEmpty()]
        $Key,
        [Parameter(Mandatory)]
        [string]
        [ValidateNotNullOrEmpty()]
        $Value,
        [Parameter()]
        [switch]
        $PassThru
    )

    process {
        $property = $InputObject.Properties | Where-Object Key -eq $Key
        if ($null -eq $property) {
            Write-Error -Message "Key '$Key' not found on configuration item $($InputObject.Path)" -TargetObject $InputObject -Category InvalidArgument
            return
        }
        $property.Value = $Value
        if ($PassThru) {
            $InputObject
        }
    }
}
<#
Functions in this module are written as independent PS1 files, and to improve module load time they
are "comiled" into this PSM1 file. If you're looking at this file prior to build, now you know how
all the functions will be loaded later. If you're looking at this file after build, now you know
why this file has so many lines :)
#>


$script:Messages = @{}
Import-LocalizedData -BindingVariable 'script:Messages' -FileName 'messages'
Export-ModuleMember -Cmdlet * -Alias * -Function *


# SIG # Begin signature block
# MIInuQYJKoZIhvcNAQcCoIInqjCCJ6YCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB
# gjcCAQSgWzBZMDQGCisGAQQBgjcCAR4wJgIDAQAABBAfzDtgWUsITrck0sYpfvNR
# AgEAAgEAAgEAAgEAAgEAMCEwCQYFKw4DAhoFAAQUipqwI5AR5riJQGjNzc6QR6Et
# T6uggiD1MIIFjTCCBHWgAwIBAgIQDpsYjvnQLefv21DiCEAYWjANBgkqhkiG9w0B
# AQwFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYD
# VQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVk
# IElEIFJvb3QgQ0EwHhcNMjIwODAxMDAwMDAwWhcNMzExMTA5MjM1OTU5WjBiMQsw
# CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu
# ZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQw
# ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz
# 7MKnJS7JIT3yithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS
# 5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7
# bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfI
# SKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jH
# trHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14
# Ztk6MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2
# h4mXaXpI8OCiEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt
# 6zPZxd9LBADMfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPR
# iQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ER
# ElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4K
# Jpn15GkvmB0t9dmpsh3lGwIDAQABo4IBOjCCATYwDwYDVR0TAQH/BAUwAwEB/zAd
# BgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wHwYDVR0jBBgwFoAUReuir/SS
# y4IxLVGLp6chnfNtyA8wDgYDVR0PAQH/BAQDAgGGMHkGCCsGAQUFBwEBBG0wazAk
# BggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEMGCCsGAQUFBzAC
# hjdodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURS
# b290Q0EuY3J0MEUGA1UdHwQ+MDwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRpZ2ljZXJ0
# LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcmwwEQYDVR0gBAowCDAGBgRV
# HSAAMA0GCSqGSIb3DQEBDAUAA4IBAQBwoL9DXFXnOF+go3QbPbYW1/e/Vwe9mqyh
# hyzshV6pGrsi+IcaaVQi7aSId229GhT0E0p6Ly23OO/0/4C5+KH38nLeJLxSA8hO
# 0Cre+i1Wz/n096wwepqLsl7Uz9FDRJtDIeuWcqFItJnLnU+nBgMTdydE1Od/6Fmo
# 8L8vC6bp8jQ87PcDx4eo0kxAGTVGamlUsLihVo7spNU96LHc/RzY9HdaXFSMb++h
# UD38dglohJ9vytsgjTVgHAIDyyCwrFigDkBjxZgiwbJZ9VVrzyerbHbObyMt9H5x
# aiNrIv8SuFQtJ37YOtnwtoeW/VvRXKwYw02fc7cBqZ9Xql4o4rmUMIIGrjCCBJag
# AwIBAgIQBzY3tyRUfNhHrP0oZipeWzANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQG
# EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl
# cnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjIw
# MzIzMDAwMDAwWhcNMzcwMzIyMjM1OTU5WjBjMQswCQYDVQQGEwJVUzEXMBUGA1UE
# ChMORGlnaUNlcnQsIEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQg
# UlNBNDA5NiBTSEEyNTYgVGltZVN0YW1waW5nIENBMIICIjANBgkqhkiG9w0BAQEF
# AAOCAg8AMIICCgKCAgEAxoY1BkmzwT1ySVFVxyUDxPKRN6mXUaHW0oPRnkyibaCw
# zIP5WvYRoUQVQl+kiPNo+n3znIkLf50fng8zH1ATCyZzlm34V6gCff1DtITaEfFz
# sbPuK4CEiiIY3+vaPcQXf6sZKz5C3GeO6lE98NZW1OcoLevTsbV15x8GZY2UKdPZ
# 7Gnf2ZCHRgB720RBidx8ald68Dd5n12sy+iEZLRS8nZH92GDGd1ftFQLIWhuNyG7
# QKxfst5Kfc71ORJn7w6lY2zkpsUdzTYNXNXmG6jBZHRAp8ByxbpOH7G1WE15/teP
# c5OsLDnipUjW8LAxE6lXKZYnLvWHpo9OdhVVJnCYJn+gGkcgQ+NDY4B7dW4nJZCY
# OjgRs/b2nuY7W+yB3iIU2YIqx5K/oN7jPqJz+ucfWmyU8lKVEStYdEAoq3NDzt9K
# oRxrOMUp88qqlnNCaJ+2RrOdOqPVA+C/8KI8ykLcGEh/FDTP0kyr75s9/g64ZCr6
# dSgkQe1CvwWcZklSUPRR8zZJTYsg0ixXNXkrqPNFYLwjjVj33GHek/45wPmyMKVM
# 1+mYSlg+0wOI/rOP015LdhJRk8mMDDtbiiKowSYI+RQQEgN9XyO7ZONj4KbhPvbC
# dLI/Hgl27KtdRnXiYKNYCQEoAA6EVO7O6V3IXjASvUaetdN2udIOa5kM0jO0zbEC
# AwEAAaOCAV0wggFZMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFLoW2W1N
# hS9zKXaaL3WMaiCPnshvMB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9P
# MA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEFBQcDCDB3BggrBgEFBQcB
# AQRrMGkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggr
# BgEFBQcwAoY1aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1
# c3RlZFJvb3RHNC5jcnQwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGln
# aWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwIAYDVR0gBBkwFzAI
# BgZngQwBBAIwCwYJYIZIAYb9bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQB9WY7Ak7Zv
# mKlEIgF+ZtbYIULhsBguEE0TzzBTzr8Y+8dQXeJLKftwig2qKWn8acHPHQfpPmDI
# 2AvlXFvXbYf6hCAlNDFnzbYSlm/EUExiHQwIgqgWvalWzxVzjQEiJc6VaT9Hd/ty
# dBTX/6tPiix6q4XNQ1/tYLaqT5Fmniye4Iqs5f2MvGQmh2ySvZ180HAKfO+ovHVP
# ulr3qRCyXen/KFSJ8NWKcXZl2szwcqMj+sAngkSumScbqyQeJsG33irr9p6xeZmB
# o1aGqwpFyd/EjaDnmPv7pp1yr8THwcFqcdnGE4AJxLafzYeHJLtPo0m5d2aR8XKc
# 6UsCUqc3fpNTrDsdCEkPlM05et3/JWOZJyw9P2un8WbDQc1PtkCbISFA0LcTJM3c
# HXg65J6t5TRxktcma+Q4c6umAU+9Pzt4rUyt+8SVe+0KXzM5h0F4ejjpnOHdI/0d
# KNPH+ejxmF/7K9h+8kaddSweJywm228Vex4Ziza4k9Tm8heZWcpw8De/mADfIBZP
# J/tgZxahZrrdVcA6KYawmKAr7ZVBtzrVFZgxtGIJDwq9gdkT/r+k0fNX2bwE+oLe
# Mt8EifAAzV3C+dAjfwAL5HYCJtnwZXZCpimHCUcr5n8apIUP/JiW9lVUKx+A+sDy
# Divl1vupL0QVSucTDh3bNzgaoSv27dZ8/DCCBsAwggSooAMCAQICEAxNaXJLlPo8
# Kko9KQeAPVowDQYJKoZIhvcNAQELBQAwYzELMAkGA1UEBhMCVVMxFzAVBgNVBAoT
# DkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBUcnVzdGVkIEc0IFJT
# QTQwOTYgU0hBMjU2IFRpbWVTdGFtcGluZyBDQTAeFw0yMjA5MjEwMDAwMDBaFw0z
# MzExMjEyMzU5NTlaMEYxCzAJBgNVBAYTAlVTMREwDwYDVQQKEwhEaWdpQ2VydDEk
# MCIGA1UEAxMbRGlnaUNlcnQgVGltZXN0YW1wIDIwMjIgLSAyMIICIjANBgkqhkiG
# 9w0BAQEFAAOCAg8AMIICCgKCAgEAz+ylJjrGqfJru43BDZrboegUhXQzGias0BxV
# Hh42bbySVQxh9J0Jdz0Vlggva2Sk/QaDFteRkjgcMQKW+3KxlzpVrzPsYYrppijb
# kGNcvYlT4DotjIdCriak5Lt4eLl6FuFWxsC6ZFO7KhbnUEi7iGkMiMbxvuAvfTux
# ylONQIMe58tySSgeTIAehVbnhe3yYbyqOgd99qtu5Wbd4lz1L+2N1E2VhGjjgMtq
# edHSEJFGKes+JvK0jM1MuWbIu6pQOA3ljJRdGVq/9XtAbm8WqJqclUeGhXk+DF5m
# jBoKJL6cqtKctvdPbnjEKD+jHA9QBje6CNk1prUe2nhYHTno+EyREJZ+TeHdwq2l
# fvgtGx/sK0YYoxn2Off1wU9xLokDEaJLu5i/+k/kezbvBkTkVf826uV8MefzwlLE
# 5hZ7Wn6lJXPbwGqZIS1j5Vn1TS+QHye30qsU5Thmh1EIa/tTQznQZPpWz+D0CuYU
# bWR4u5j9lMNzIfMvwi4g14Gs0/EH1OG92V1LbjGUKYvmQaRllMBY5eUuKZCmt2Fk
# +tkgbBhRYLqmgQ8JJVPxvzvpqwcOagc5YhnJ1oV/E9mNec9ixezhe7nMZxMHmsF4
# 7caIyLBuMnnHC1mDjcbu9Sx8e47LZInxscS451NeX1XSfRkpWQNO+l3qRXMchH7X
# zuLUOncCAwEAAaOCAYswggGHMA4GA1UdDwEB/wQEAwIHgDAMBgNVHRMBAf8EAjAA
# MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMCAGA1UdIAQZMBcwCAYGZ4EMAQQCMAsG
# CWCGSAGG/WwHATAfBgNVHSMEGDAWgBS6FtltTYUvcyl2mi91jGogj57IbzAdBgNV
# HQ4EFgQUYore0GH8jzEU7ZcLzT0qlBTfUpwwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0
# cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5NlNI
# QTI1NlRpbWVTdGFtcGluZ0NBLmNybDCBkAYIKwYBBQUHAQEEgYMwgYAwJAYIKwYB
# BQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBYBggrBgEFBQcwAoZMaHR0
# cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5
# NlNIQTI1NlRpbWVTdGFtcGluZ0NBLmNydDANBgkqhkiG9w0BAQsFAAOCAgEAVaoq
# GvNG83hXNzD8deNP1oUj8fz5lTmbJeb3coqYw3fUZPwV+zbCSVEseIhjVQlGOQD8
# adTKmyn7oz/AyQCbEx2wmIncePLNfIXNU52vYuJhZqMUKkWHSphCK1D8G7WeCDAJ
# +uQt1wmJefkJ5ojOfRu4aqKbwVNgCeijuJ3XrR8cuOyYQfD2DoD75P/fnRCn6wC6
# X0qPGjpStOq/CUkVNTZZmg9U0rIbf35eCa12VIp0bcrSBWcrduv/mLImlTgZiEQU
# 5QpZomvnIj5EIdI/HMCb7XxIstiSDJFPPGaUr10CU+ue4p7k0x+GAWScAMLpWnR1
# DT3heYi/HAGXyRkjgNc2Wl+WFrFjDMZGQDvOXTXUWT5Dmhiuw8nLw/ubE19qtcfg
# 8wXDWd8nYiveQclTuf80EGf2JjKYe/5cQpSBlIKdrAqLxksVStOYkEVgM4DgI974
# A6T2RUflzrgDQkfoQTZxd639ouiXdE4u2h4djFrIHprVwvDGIqhPm73YHJpRxC+a
# 9l+nJ5e6li6FV8Bg53hWf2rvwpWaSxECyIKcyRoFfLpxtU56mWz06J7UWpjIn7+N
# uxhcQ/XQKujiYu54BNu90ftbCqhwfvCXhHjjCANdRyxjqCU4lwHSPzra5eX25pvc
# fizM/xdMTQCi2NYBDriL7ubgclWJLCcZYfZ3AYwwggbmMIIEzqADAgECAhB3vQ4D
# obcI+FSrBnIQ2QRHMA0GCSqGSIb3DQEBCwUAMFMxCzAJBgNVBAYTAkJFMRkwFwYD
# VQQKExBHbG9iYWxTaWduIG52LXNhMSkwJwYDVQQDEyBHbG9iYWxTaWduIENvZGUg
# U2lnbmluZyBSb290IFI0NTAeFw0yMDA3MjgwMDAwMDBaFw0zMDA3MjgwMDAwMDBa
# MFkxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMS8wLQYD
# VQQDEyZHbG9iYWxTaWduIEdDQyBSNDUgQ29kZVNpZ25pbmcgQ0EgMjAyMDCCAiIw
# DQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANZCTfnjT8Yj9GwdgaYw90g9z9Dl
# jeUgIpYHRDVdBs8PHXBg5iZU+lMjYAKoXwIC947Jbj2peAW9jvVPGSSZfM8RFpsf
# e2vSo3toZXer2LEsP9NyBjJcW6xQZywlTVYGNvzBYkx9fYYWlZpdVLpQ0LB/okQZ
# 6dZubD4Twp8R1F80W1FoMWMK+FvQ3rpZXzGviWg4QD4I6FNnTmO2IY7v3Y2FQVWe
# HLw33JWgxHGnHxulSW4KIFl+iaNYFZcAJWnf3sJqUGVOU/troZ8YHooOX1ReveBb
# z/IMBNLeCKEQJvey83ouwo6WwT/Opdr0WSiMN2WhMZYLjqR2dxVJhGaCJedDCndS
# sZlRQv+hst2c0twY2cGGqUAdQZdihryo/6LHYxcG/WZ6NpQBIIl4H5D0e6lSTmpP
# VAYqgK+ex1BC+mUK4wH0sW6sDqjjgRmoOMieAyiGpHSnR5V+cloqexVqHMRp5rC+
# QBmZy9J9VU4inBDgoVvDsy56i8Te8UsfjCh5MEV/bBO2PSz/LUqKKuwoDy3K1JyY
# ikptWjYsL9+6y+JBSgh3GIitNWGUEvOkcuvuNp6nUSeRPPeiGsz8h+WX4VGHaeki
# zIPAtw9FbAfhQ0/UjErOz2OxtaQQevkNDCiwazT+IWgnb+z4+iaEW3VCzYkmeVmd
# a6tjcWKQJQ0IIPH/AgMBAAGjggGuMIIBqjAOBgNVHQ8BAf8EBAMCAYYwEwYDVR0l
# BAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU2rON
# wCSQo2t30wygWd0hZ2R2C3gwHwYDVR0jBBgwFoAUHwC/RoAK/Hg5t6W0Q9lWULvO
# ljswgZMGCCsGAQUFBwEBBIGGMIGDMDkGCCsGAQUFBzABhi1odHRwOi8vb2NzcC5n
# bG9iYWxzaWduLmNvbS9jb2Rlc2lnbmluZ3Jvb3RyNDUwRgYIKwYBBQUHMAKGOmh0
# dHA6Ly9zZWN1cmUuZ2xvYmFsc2lnbi5jb20vY2FjZXJ0L2NvZGVzaWduaW5ncm9v
# dHI0NS5jcnQwQQYDVR0fBDowODA2oDSgMoYwaHR0cDovL2NybC5nbG9iYWxzaWdu
# LmNvbS9jb2Rlc2lnbmluZ3Jvb3RyNDUuY3JsMFYGA1UdIARPME0wQQYJKwYBBAGg
# MgEyMDQwMgYIKwYBBQUHAgEWJmh0dHBzOi8vd3d3Lmdsb2JhbHNpZ24uY29tL3Jl
# cG9zaXRvcnkvMAgGBmeBDAEEATANBgkqhkiG9w0BAQsFAAOCAgEACIhyJsav+qxf
# BsCqjJDa0LLAopf/bhMyFlT9PvQwEZ+PmPmbUt3yohbu2XiVppp8YbgEtfjry/Rh
# ETP2ZSW3EUKL2Glux/+VtIFDqX6uv4LWTcwRo4NxahBeGQWn52x/VvSoXMNOCa1Z
# a7j5fqUuuPzeDsKg+7AE1BMbxyepuaotMTvPRkyd60zsvC6c8YejfzhpX0FAZ/ZT
# fepB7449+6nUEThG3zzr9s0ivRPN8OHm5TOgvjzkeNUbzCDyMHOwIhz2hNabXAAC
# 4ShSS/8SS0Dq7rAaBgaehObn8NuERvtz2StCtslXNMcWwKbrIbmqDvf+28rrvBfL
# uGfr4z5P26mUhmRVyQkKwNkEcUoRS1pkw7x4eK1MRyZlB5nVzTZgoTNTs/Z7KtWJ
# QDxxpav4mVn945uSS90FvQsMeAYrz1PYvRKaWyeGhT+RvuB4gHNU36cdZytqtq5N
# iYAkCFJwUPMB/0SuL5rg4UkI4eFb1zjRngqKnZQnm8qjudviNmrjb7lYYuA2eDYB
# +sGniXomU6Ncu9Ky64rLYwgv/h7zViniNZvY/+mlvW1LWSyJLC9Su7UpkNpDR7xy
# 3bzZv4DB3LCrtEsdWDY3ZOub4YUXmimi/eYI0pL/oPh84emn0TCOXyZQK8ei4pd3
# iu/YTT4m65lAYPM8Zwy2CHIpNVOBNNwwggcAMIIE6KADAgECAgxVg/P+wUlFDmSx
# M2wwDQYJKoZIhvcNAQELBQAwWTELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2Jh
# bFNpZ24gbnYtc2ExLzAtBgNVBAMTJkdsb2JhbFNpZ24gR0NDIFI0NSBDb2RlU2ln
# bmluZyBDQSAyMDIwMB4XDTIxMTEyOTE1NDEwMVoXDTIyMTEzMDE1NDEwMVowbjEL
# MAkGA1UEBhMCREsxETAPBgNVBAcMCEJyw7huZGJ5MR4wHAYDVQQKExVNaWxlc3Rv
# bmUgU3lzdGVtcyBBL1MxDDAKBgNVBAsMA1ImRDEeMBwGA1UEAxMVTWlsZXN0b25l
# IFN5c3RlbXMgQS9TMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0Eti
# hqPkmu1KV6LRSN9xz96UtyFGEZhOdBFPstJsSKMXQRCYcH3wcVxz6/pERdOTllrU
# uojlhmJKXUJ04ak7aHxY6/5WzUQZmm5b5uoZ27p9qSOVVgkYnpgwPAs41b5bC8qB
# V+NSELQniXQEOuacIRHN+oynZitag8Fy/7qtDzYaqmH74PBABr7vBMOUovEuxAxa
# r6v0dRIc2MYbWqOfF6jTJTX9fG0hW4nGQsC04EbENAdMHCZubiTswL20FgbjNq9O
# LpCp+Eu7sYRkmnHz2Kxd++9QlS4DtmI6Hbw6jy7a2WQP0vAsrqdVd1nh2DCbf058
# rHUTXNT/csXv82uSpwdLpSZVZigaKFsTmBmW94sVFK+TQYTe/4lpo+F/sMyPrw0i
# Fv/jJMlp5e3WKa8leiAIFiEfu7vnmI1FFslOlVfHYHc1fe2ERCe1DDW/hq3KFz8D
# 1q2CGMJpY6zY2iZ/mq2bJnMRASM9qOtRdTYLxPzm697bUdgK7p8SLtm1TzbzS1Js
# XpgRslxWXUWUAkSUeEeMXZHaF3wXZIn507FD/oupj0Goc4riHPxjhm9avY5tcMGY
# 8pflyYG1OOjNUlcHhW/cFX3/Tzr4UB2/sWkJ1Jopm22ZopCoDf905LrmgZOlVXc8
# cgApylcnpUKN9bl9XfqXxhYiaw0nz4hfImpjkTsCAwEAAaOCAbEwggGtMA4GA1Ud
# DwEB/wQEAwIHgDCBmwYIKwYBBQUHAQEEgY4wgYswSgYIKwYBBQUHMAKGPmh0dHA6
# Ly9zZWN1cmUuZ2xvYmFsc2lnbi5jb20vY2FjZXJ0L2dzZ2NjcjQ1Y29kZXNpZ25j
# YTIwMjAuY3J0MD0GCCsGAQUFBzABhjFodHRwOi8vb2NzcC5nbG9iYWxzaWduLmNv
# bS9nc2djY3I0NWNvZGVzaWduY2EyMDIwMFYGA1UdIARPME0wQQYJKwYBBAGgMgEy
# MDQwMgYIKwYBBQUHAgEWJmh0dHBzOi8vd3d3Lmdsb2JhbHNpZ24uY29tL3JlcG9z
# aXRvcnkvMAgGBmeBDAEEATAJBgNVHRMEAjAAMEUGA1UdHwQ+MDwwOqA4oDaGNGh0
# dHA6Ly9jcmwuZ2xvYmFsc2lnbi5jb20vZ3NnY2NyNDVjb2Rlc2lnbmNhMjAyMC5j
# cmwwEwYDVR0lBAwwCgYIKwYBBQUHAwMwHwYDVR0jBBgwFoAU2rONwCSQo2t30wyg
# Wd0hZ2R2C3gwHQYDVR0OBBYEFIdg9gMxxfcpe7gViT9QiXomUUGjMA0GCSqGSIb3
# DQEBCwUAA4ICAQAY8MM+XlsN3AEj6VASoSGTEVEIdMSZD0p2UPa8zkTWm7ZX0JLN
# O81eTCDRXzdS9jAz7U+ldJZwKYeogKshoBkPN0jBz+BCQNhOBYNfpGCFdYwH/cIF
# RPbcPB2sIQpe9Lr4+ZrU7MT5kx2ltznSaLHDf4wwvow0FsdrWfqJhNMsg4eYAuXU
# xnq0dmG2eJzB8XzoiFNhfv215Z45zYlG2vlczZnV2H8VfgvGA1Y6zLE7hLn5xaWg
# s+lwp2e87KIshP8qd+0DK3H+g0pFdcSQKjNnEgoASBAsdxSI0OLbZOLElOqAuaD2
# RAeEleEm0Ww1jZrncpDr8bq4LqT1q+fBMdPOhU28L+V20lHJrLwpjSWtatnKzEn2
# C5pBMgO2tcjPf7HH/Q30HIgCN6e9CxTxT/oO3eyxl7E9KtBL3edjE2/qFhpLVbdo
# P0KUb3go5jsnF9nmkualdsFICtzI7q4QARQi9+toixHk53eCUnxYEo9E01L45y6E
# BrvUWjvdj/A4RQZNjaiokzMYRluj6detWnN1cP0S18z3gwDlKA61bEAdpqvo3ifE
# oDtsCVQQty0duVp8nPzh8jT12V9+nQ9WQfch8x8TEm1qyNpHsIYDKGwsSLju95Zq
# ApYO7GFvzxvRjZMnUw85ePn/n8BY6HhALbPUmkn4PaXEee36XtllOAVvozGCBi4w
# ggYqAgEBMGkwWTELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEdsb2JhbFNpZ24gbnYt
# c2ExLzAtBgNVBAMTJkdsb2JhbFNpZ24gR0NDIFI0NSBDb2RlU2lnbmluZyBDQSAy
# MDIwAgxVg/P+wUlFDmSxM2wwCQYFKw4DAhoFAKB4MBgGCisGAQQBgjcCAQwxCjAI
# oAKAAKECgAAwGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIB
# CzEOMAwGCisGAQQBgjcCARUwIwYJKoZIhvcNAQkEMRYEFKh3TcbyhqBxgXfrAC4f
# l1miQPOgMA0GCSqGSIb3DQEBAQUABIICAHTFQDquYSPJGq25Rv79pTNMK7IdsC85
# P3mWWMGX4slB9R1bCr7XrcFHwFmE7B6dNS77F01HrtYVlZYmGM0ARG8rUx6I3jm7
# TGw/cVZRaFqcH+xMefE6A7DMJwE3K3Z/ElHw0RmElrHUL5reUDEr6BcixoHFg3tJ
# HP/NyPwMblzyGLbLYo9By88sgnd6uSj8vWcKP53ZbQBa31Vfj2fBC/Bh4fUEtcll
# V4laIjX8PHHAoQVpGvRM3FFrkkSRI5Ci9F88p4bVTxQE2bFexA9kdfOt1WN4Iy5Y
# pOlLue1od9TZskNekRe8qE6QKmCsvGZJ64LlifC0sfdZTHcFRYhMFlMmYeN96uaR
# Ps4K3D2tI7VUXGoYgqnwKOw3BuFmEJAXsJt6Lby5uyZV2OkYr0JLQu1UYmlPj0dA
# Iq688s0k7oe7kLEbAzE0hBjbSGcF/0+pqRrIwbBKiVe9ud1DShJQ0GV+Pse1tVRv
# xG4iPemy5iE9nGIBfl6Pg6//W8t80VhAz77vU3XDCwQpbRJucrUfpDRC3tXPYevX
# XR9uCMQG4K+tf11vCLnIMOZ7Z/+P7GJhw3/iZDdf+c5WAd0KI4bl+E1uIXVAzKsV
# MJi4hYbhDaJq9PH/yTqsG3fFsri8k+6Wpx9xbRQJmC0Mp7qGdX40BxJ2auWSu89I
# XjSah5TqMjZHoYIDIDCCAxwGCSqGSIb3DQEJBjGCAw0wggMJAgEBMHcwYzELMAkG
# A1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdp
# Q2VydCBUcnVzdGVkIEc0IFJTQTQwOTYgU0hBMjU2IFRpbWVTdGFtcGluZyBDQQIQ
# DE1pckuU+jwqSj0pB4A9WjANBglghkgBZQMEAgEFAKBpMBgGCSqGSIb3DQEJAzEL
# BgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTIyMTEzMDA1NDIwOVowLwYJKoZI
# hvcNAQkEMSIEIBypWmeqfO7/SDc2T+G5YtT5De43UKEA6ChDlwj/WIRKMA0GCSqG
# SIb3DQEBAQUABIICAD4tNUq5UMOQN2sd+Fq7x9vghqb7KoDznzGqbXs5wmIahYfi
# j0DgYirNHtcLwWwTuYvVsYq/5PwqG6IxU3yrsqKQCS5sl7n4LBzaQtF5GRqchbZH
# c6KuV2uteVlORaZYy8pJhUl7X9fIltDsdOgtS5KLuggkYHCEq0mb4UJCVSTjq6TI
# 4k3aTjO9CmfDQebK3uGU3Qs275wN03BamxOs6o3tG7J54qluMnY2O/jCUBcYa+lK
# g/CFFjnZhBiPiUQNg1LqB+b46p2v2vm+XJpXmsClQ+iPAclhqvZb0jbZY2T1Mo5x
# Q8kmY1ZhZXacMuvNgKSlTDQ/lhJ5tDjogxAOct5tapNbMY04nvfUpdS7zVzwmglM
# TtThWyfPfff7XLZwl8bzwJQqGEGAFbcs7uANLOkB2yWqvcovNXOHG1/HVNPQwDUR
# OejGIyDp0XHGi+Fwu2gn9LoQcVPfhbY2qlQZm7Ae6+eAcSNvldnly7u6Hzi5cYPQ
# kZzbyDEaZEU5N/jJYP7it3pEG3yRrrIDetQlL89VwWSIINv4WerZ4gKJWXwa4T8q
# TgxYrVi+3ophCt2+swR62N12TRSFqQ79woX2cfBeHAikgIJa5MikvB0Ench+cPjV
# ioEACxuqKkJgSiLsyMpugA4Re+Qs0ku0Nz6auPJkUTBdrO7y/eP69tvBLQOw
# SIG # End signature block