MilestonePSTools.psm1


using namespace MilestonePSTools
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) {
            try {
                switch ($p.ValueType) {
                    'Progress' {
                        $this.($p.Key) = [int]$p.Value
                    }
                    'Tick' {
                        $this.($p.Key) = [bool]::Parse($p.Value)
                    }
                    default {
                        $this.($p.Key) = $p.Value
                    }
                }
            } catch {
                if ($p -in 'Progress', 'Path', 'ErrorCode', 'ErrorText', 'State' ) {
                    throw
                }
            }

        }
    }
}

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) {
        $members = ($this.GetType().GetMembers() | Where-Object MemberType -EQ 'Property').Name
        foreach ($p in $InvokeItem.Properties) {
            if ($p.Key -notin $members) {
                continue
            }
            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)
    [string[]] $Modules = @()

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

    LocalJobRunner ([string[]]$Modules) {
        $this.Modules = $Modules
        $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()
        if ($this.Modules.Count -gt 0) {
            $iss.ImportPSModule($this.Modules)
        }
        $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] $PlaybackDefault
    [bool] $Recorded
    [string] $RecordingTrack
    [bool] $UseEdge
    [guid] $StreamReferenceId
    [hashtable] $Settings
    [hashtable] $ValueTypeInfo
    hidden [VideoOS.Platform.ConfigurationItems.Camera] $Camera
    hidden [bool] $UseRawValues
    hidden [System.Collections.Generic.Dictionary[string, string]] $RecordToValues

    [void] Update() {
        $this.Camera.DeviceDriverSettingsFolder.ClearChildrenCache()
        $this.Camera.StreamFolder.ClearChildrenCache()
        $deviceDriverSettings = $this.Camera.DeviceDriverSettingsFolder.DeviceDriverSettings[0]
        $streamUsages = $this.Camera.StreamFolder.Streams[0]

        $stream = $deviceDriverSettings.StreamChildItems | Where-Object DisplayName -EQ $this.Name
        $streamUsage = $streamUsages.StreamUsageChildItems | Where-Object {
            $_.StreamReferenceId -eq $_.StreamReferenceIdValues[$stream.DisplayName]
        }
        if ($streamUsage) {
            $this.RecordToValues = $streamUsage.RecordToValues
        }
        $this.DisplayName = $streamUsage.Name
        $this.Enabled = $null -ne $streamUsage

        $this.LiveDefault = $streamUsage.LiveDefault
        $this.LiveMode = $streamUsage.LiveMode

        # StreamUsageChildItem.Record is true only for the primary recording track. Or for the recorded track on 2023 R1 and older.
        # It will be false for the secondary recording track on 2023 R2 and later.
        $this.Recorded = $streamUsage.Record -or ($streamUsage.RecordToValues.Count -gt 0 -and -not [string]::IsNullOrWhiteSpace($streamUsage.RecordTo))
        $this.RecordingTrack = $streamUsage.RecordTo
        $this.PlaybackDefault = if ($streamUsage.RecordToValues.Count -gt 0) { $streamUsage.DefaultPlayback } else { $streamUsage.Record -eq $true }
        $this.UseEdge = $streamUsage.UseEdge
        $this.StreamReferenceId = if ($streamUsages.StreamUsageChildItems.Count -gt 0) { $streamUsages.StreamUsageChildItems[0].StreamReferenceIdValues[$this.Name] } else { [guid]::Empty }
        $parsedSettings = $stream | ConvertFrom-ConfigChildItem -RawValues:($this.UseRawValues)
        $this.Settings = $parsedSettings.Properties.Clone()
        $this.ValueTypeInfo = $parsedSettings.ValueTypeInfo.Clone()
    }

    [string] GetRecordingTrackName() {
        if ($this.RecordToValues.Count) {
            return ($this.RecordToValues.GetEnumerator() | Where-Object Value -EQ $this.RecordingTrack).Key
        } elseif ($this.Recorded) {
            return 'Primary recording'
        } else {
            return 'No recording'
        }
    }
}

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-VmsManagementServer).RoleFolder.Roles | Where-Object Name -Like $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 ($null -eq $inputData -or $inputData.Count -eq 0) { return [guid]::Empty }
        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])) {
            $securityNamespaces = Get-SecurityNamespaceValues
            $result = [string[]]@()
            foreach ($value in $inputData) {
                $id = [guid]::Empty
                if (-not [guid]::TryParse($value, [ref]$id)) {
                    try {
                        $id = if ($securityNamespaces.SecurityNamespacesByName.ContainsKey($value)) { $securityNamespaces.SecurityNamespacesByName[$value] } else { $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 {
                    if ($_ -eq 'Always') {
                        @(
                            $always = [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]@{
                                DisplayName  = 'Always'
                                ItemCategory = 'Item'
                                ItemType     = 'TimeProfile'
                                Path         = 'TimeProfile[11111111-1111-1111-1111-111111111111]'
                                ParentPath   = '/TimeProfileFolder'
                            }
                            [VideoOS.Platform.ConfigurationItems.TimeProfile]::new((Get-VmsManagementServer).ServerId, $always)
                        )
                    } elseif ($_ -eq 'Default') {
                        @(
                            $default = [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]@{
                                DisplayName  = 'Default'
                                ItemCategory = 'Item'
                                ItemType     = 'TimeProfile'
                                Path         = 'TimeProfile[00000000-0000-0000-0000-000000000000]'
                                ParentPath   = '/TimeProfileFolder'
                            }
                            [VideoOS.Platform.ConfigurationItems.TimeProfile]::new((Get-VmsManagementServer).ServerId, $default)
                        )
                    } else {
                        (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)
        } elseif ($inputData -is [int]) {
            return [bool]$inputData
        } elseif ($inputData -is [VideoOS.ConfigurationApi.ClientService.EnablePropertyInfo]) {
            return $inputData.Enabled
        }
        throw "Unexpected type '$($inputData.GetType().FullName)'"
    }

    [string] ToString() {
        return '[BooleanTransformAttribute()]'
    }
}

class ReplaceHardwareTaskInfo {
    [string]
    $HardwareName

    [videoos.platform.proxy.ConfigApi.ConfigurationItemPath]
    $HardwarePath

    [videoos.platform.proxy.ConfigApi.ConfigurationItemPath]
    $RecorderPath

    [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]
    $Task
}

class HardwareDriverTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        $driversById = @{}
        $driversByName = @{}
        $rec = $null
        return ($inputData | ForEach-Object {
                $obj = $_
                if ($obj -is [VideoOS.Platform.ConfigurationItems.HardwareDriver]) {
                    $obj
                    return
                }

                if ($driversById.Count -eq 0) {
                    $rec = Get-VmsRecordingServer | Select-Object -First 1
                    $rec | Get-VmsHardwareDriver | ForEach-Object {
                        $driversById[$_.Number] = $_
                        $driversByName[$_.Name] = $_
                    }
                }
                switch ($obj.GetType()) {
                ([int]) {
                        if (-not $driversById.ContainsKey($obj)) {
                            throw [VideoOS.Platform.PathNotFoundMIPException]::new('Hardware driver with ID {0} not found on recording server "{1}".' -f $obj, $_)
                        }
                        $driversById[$obj]
                    }

                ([string]) {
                        $driversByName[$obj]
                    }

                    default {
                        throw [System.InvalidOperationException]::new("Unable to transform object of type $($_.FullName) to type VideoOS.Platform.ConfigurationItems.HardwareDriver")
                    }
                }
            })
    }

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

class ConfigurationItemPathTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        return ($inputData | ForEach-Object {
                $obj = $_
                if ($obj -as [guid]) {
                    $obj
                    return
                }
                $configItemPath = $obj -as [VideoOS.Platform.Proxy.ConfigApi.ConfigurationItemPath]
                if ($null -eq $configItemPath) {
                    throw ([InvalidOperationException]::new("Invalid configuration item path string '$obj'"))
                }
                $configItemPath
            })
    }

    [string] ToString() {
        return '[ConfigurationItemPathTransformAttribute()]'
    }
}

class SecureStringTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        return ($inputData | ForEach-Object {
                $obj = $_
                if ($obj -as [securestring]) {
                    $obj
                    return
                }
                if ($null -eq $obj -or $obj -isnot [string]) {
                    throw 'Expected object of type SecureString or String.'
                }
                $obj | ConvertTo-SecureString -AsPlainText -Force
            })
    }

    [string] ToString() {
        return '[SecureStringTransformAttribute()]'
    }
}

class BoolTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        return ($inputData | ForEach-Object {
                $obj = $_
                if ($obj -is [bool]) {
                    $obj
                    return
                }
                if ($null -eq $obj -or -not [bool]::TryParse($obj, [ref]$obj)) {
                    throw "Failed to parse '$obj' as [bool]"
                }
                $obj
            })
    }

    [string] ToString() {
        return '[BoolTransformAttribute()]'
    }
}

class LoginProviderTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        if ($inputData -is [VideoOS.Platform.ConfigurationItems.LoginProvider] -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is [VideoOS.Platform.ConfigurationItems.LoginProvider])) {
            return $inputData
        }
        try {
            if ($inputData.LoginProvider) {
                $inputData = $inputData.LoginProvider
            }
            if ($inputData -is [string] -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is [string])) {
                $items = Get-VmsLoginProvider | Where-Object Name -EQ $inputData
                if ($null -eq $items -or $items.Count -eq 0) {
                    throw ([System.Management.Automation.ItemNotFoundException]::new("Login provider '$($inputData)' not found."))
                }
                if ($inputData -is [string]) {
                    return $items[0]
                }
                return $items
            } else {
                throw "Unexpected type '$($inputData.GetType().FullName)'"
            }
        } catch {
            throw $_.Exception
        }
    }

    [string] ToString() {
        return '[LoginProviderTransformAttribute()]'
    }
}

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

    [string] ToString() {
        return '[LoginProviderTransformAttribute()]'
    }
}

class ClientProfileTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        $expectedType = [VideoOS.Platform.ConfigurationItems.ClientProfile]
        $itemType = $expectedType.Name

        if ($inputData -is $expectedType -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is $expectedType)) {
            return $inputData
        }
        try {
            $items = $inputData | ForEach-Object {
                $stringValue = $_.ToString() -replace "^$ItemType\[(.+)\](?:/.+)?", '$1'
                $id = [guid]::Empty
                if ([guid]::TryParse($stringValue, [ref]$id)) {
                    Get-VmsClientProfile -Id $stringValue -ErrorAction Stop
                } else {
                    Get-VmsClientProfile | Where-Object Name -EQ $_
                }
            }
            if ($null -eq $items) {
                throw ([System.Management.Automation.ItemNotFoundException]::new("$itemType '$($inputData)' not found."))
            }
            return $items
        } catch {
            throw $_.Exception
        }
    }

    [string] ToString() {
        return '[ClientProfileTransformAttribute()]'
    }
}

class PropertyCollectionTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        if ($inputData -is [System.Collections.IDictionary]) {
            return $inputData
        }
        try {
            $hashtable = @{}
            $inputData.GetEnumerator() | ForEach-Object {
                if ($null -eq ($_ | Get-Member -Name Key) -or $null -eq ($_ | Get-Member -Name Value)) {
                    throw 'Key and Value properties most both be present in a property collection.'
                }
                $hashtable[$_.Key] = $_.Value
            }
            return $hashtable
        } catch {
            throw $_.Exception
        }
    }

    [string] ToString() {
        return '[PropertyCollectionTransformAttribute()]'
    }
}

class RuleNameTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        $expectedType = [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]
        $itemType = 'Rule'

        if ($inputData -is $expectedType -or ($inputData -is [system.collections.ienumerable] -and $inputData[0] -is $expectedType)) {
            return $inputData
        }
        try {
            $items = $inputData | ForEach-Object {
                $stringValue = $_.ToString() -replace "^$ItemType\[(.+)\](?:/.+)?", '$1'
                $id = [guid]::Empty
                if ([guid]::TryParse($stringValue, [ref]$id)) {
                    Get-VmsRule | Where-Object Path -Match $stringValue
                } else {
                    Get-VmsRule | Where-Object DisplayName -EQ $stringValue
                }
            }
            if ($null -eq $items) {
                throw ([System.Management.Automation.ItemNotFoundException]::new("$itemType '$($inputData)' not found."))
            }
            return $items
        } catch {
            throw $_.Exception
        }
    }

    [string] ToString() {
        return '[RuleNameTransformAttribute()]'
    }
}

class FailoverGroupNameTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        $expectedType = [VideoOS.Platform.ConfigurationItems.FailoverGroup]
        $itemType = 'FailoverGroup'

        if ($null -eq ($inputData | Where-Object { $null -ne $_ -and $_ -isnot $expectedType })) {
            return $inputData
        }

        try {
            $items = $inputData | ForEach-Object {
                $stringValue = $_.ToString() -replace "^$ItemType\[(.+)\](?:/.+)?", '$1'
                $id = [guid]::Empty
                if ([guid]::TryParse($stringValue, [ref]$id)) {
                    Get-VmsFailoverGroup -Id $stringValue -ErrorAction SilentlyContinue
                } else {
                    Get-VmsFailoverGroup | Where-Object Name -EQ $stringValue | Select-Object -First 1
                }
            }
            if ($null -eq $items) {
                throw ([System.Management.Automation.ItemNotFoundException]::new("$itemType '$($inputData)' not found."))
            }
            return $items
        } catch {
            throw $_.Exception
        }
    }

    [string] ToString() {
        return '[FailoverGroupNameTransformAttribute()]'
    }
}

class FailoverRecorderNameTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        $expectedType = [VideoOS.Platform.ConfigurationItems.FailoverRecorder]
        $itemType = 'FailoverRecorder'

        if ($null -eq ($inputData | Where-Object { $null -ne $_ -and $_ -isnot $expectedType })) {
            return $inputData
        }

        try {
            $items = $inputData | ForEach-Object {
                $stringValue = $_.ToString() -replace "^$ItemType\[(.+)\](?:/.+)?", '$1'
                $id = [guid]::Empty
                if ([guid]::TryParse($stringValue, [ref]$id)) {
                    Get-VmsFailoverRecorder -Id $id -ErrorAction SilentlyContinue
                } else {
                    Get-VmsFailoverRecorder | Where-Object Name -EQ $stringValue | Select-Object -First 1
                }
            }
            if ($null -eq $items) {
                throw ([System.Management.Automation.ItemNotFoundException]::new("$itemType '$($inputData)' not found."))
            }
            return $items
        } catch {
            throw $_.Exception
        }
    }

    [string] ToString() {
        return '[FailoverRecorderNameTransformAttribute()]'
    }
}

class KindNameTransformAttribute : System.Management.Automation.ArgumentTransformationAttribute {
    [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object] $inputData) {
        $expectedType = [guid]

        if ($null -eq ($inputData | Where-Object { $_ -isnot $expectedType })) {
            return $inputData
        }

        try {
            $items = $inputData | ForEach-Object {
                $kind = [VideoOS.Platform.Kind]::($_.ToString())
                if ($kind -is [guid]) {
                    $kind
                }
            }
            if ($null -eq $items) {
                throw ([System.Management.Automation.ItemNotFoundException]::new("VideoOS item kind '$($inputData)' not found."))
            }
            return $items
        } catch {
            throw $_.Exception
        }
    }

    [string] ToString() {
        return '[KindNameTransformAttribute()]'
    }
}
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.
        if ([string]::IsNullOrWhiteSpace($Arguments[2])) {
            $wordToComplete = '*'
        } else {
            $wordToComplete = $Arguments[2].Trim('''').Trim('"')
        }

        # 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-PSCredential {
    [CmdletBinding()]
    [OutputType([pscredential])]
    param (
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
        [System.Net.NetworkCredential]
        $NetworkCredential
    )
        
    process {
        if ([string]::IsNullOrWhiteSpace($NetworkCredential.UserName)) {
            Write-Error 'NetworkCredential username is empty. This usually means the credential is the default network credential and this cannot be converted to a pscredential.'
            return
        }
        $sb = [text.stringbuilder]::new()
        if (-not [string]::IsNullOrWhiteSpace($NetworkCredential.Domain)) {
            [void]$sb.Append("$($NetworkCredential.Domain)\")
        }
        [void]$sb.Append($NetworkCredential.UserName)
        [pscredential]::new($sb.ToString(), $NetworkCredential.SecurePassword)
    }
}
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-StringFromSecureString {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [securestring]
        $SecureString
    )

    process {
        $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
        try {
            [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
        } finally {
            [System.Runtime.InteropServices.Marshal]::FreeBSTR($bstr)
        }
    }
}
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 ConvertTo-Webhook {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]
        $InputObject
    )

    process {
        try {
            [MilestonePSTools.Webhook]$InputObject
        } catch {
            Write-Error -Message $_.Exception.Message -Exception $_.Exception -TargetObject $InputObject
        }
    }
}
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 ExportVmsLoginSettings {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param ()
    
    process {
        $settings = Get-LoginSettings | Where-Object Guid -EQ ([milestonepstools.connection.milestoneconnection]::Instance.MainSite).FQID.ObjectId
        $vmsProfile = @{
            ServerAddress     = $settings.Uri
            Credential        = $settings.NetworkCredential | ConvertTo-PSCredential -ErrorAction SilentlyContinue
            BasicUser         = $settings.IsBasicUser
            SecureOnly        = $settings.SecureOnly
            IncludeChildSites = [milestonepstools.connection.milestoneconnection]::Instance.IncludeChildSites
            AcceptEula        = $true
        }
        if ($null -eq $vmsProfile.Credential) {
            $vmsProfile.Remove('Credential')
        }
        $vmsProfile
    }
}
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()]
    [RequiresInteractiveSession()]
    param ()

    begin {
        Assert-VmsRequirementsMet
    }
    
    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 Get-SecurityNamespaceValues {
    [CmdletBinding()]
    param ()
    
    process {
        if (-not [MilestonePSTools.Connection.MilestoneConnection]::Instance.Cache.ContainsKey('SecurityNamespaceValues')) {
            [MilestonePSTools.Connection.MilestoneConnection]::Instance.Cache['SecurityNamespacesById'] = [Collections.Generic.Dictionary[[string], [string]]]::new()
            
            if (($r = (Get-VmsManagementServer).RoleFolder.Roles | Where-Object RoleType -EQ 'UserDefined' | Select-Object -First 1)) {
                $task = $r.ChangeOverallSecurityPermissions()
                [MilestonePSTools.Connection.MilestoneConnection]::Instance.Cache['SecurityNamespaceValues'] = $task.SecurityNamespaceValues
                $task.SecurityNamespaceValues.GetEnumerator() | ForEach-Object {
                    [MilestonePSTools.Connection.MilestoneConnection]::Instance.Cache['SecurityNamespacesById'][$_.Value] = $_.Key
                }
            } else {
                [MilestonePSTools.Connection.MilestoneConnection]::Instance.Cache['SecurityNamespaceValues'] = [Collections.Generic.Dictionary[[string], [string]]]::new()
            }
        }
        [pscustomobject]@{
            SecurityNamespacesByName = [MilestonePSTools.Connection.MilestoneConnection]::Instance.Cache['SecurityNamespaceValues']
            SecurityNamespacesById   = [MilestonePSTools.Connection.MilestoneConnection]::Instance.Cache['SecurityNamespacesById']
        }
    }
}
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 GetVmsConnectionProfile {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(ValueFromPipelineByPropertyName, Position = 0)]
        [string]
        $Name = 'default',

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

    begin {
        if (-not (Test-Path -Path (GetVmsConnectionProfilePath))) {
            @{} | Export-Clixml -Path (GetVmsConnectionProfilePath)
        }
        $vmsProfiles = (Import-Clixml -Path (GetVmsConnectionProfilePath)) -as [hashtable]
    }

    process {
        if ($All) {
            $vmsProfiles
        } elseif ($vmsProfiles.ContainsKey($Name)) {
            $vmsProfiles[$Name]
        }
    }
}
function GetVmsConnectionProfilePath {
    [CmdletBinding()]
    [OutputType([string])]
    param()
    
    process {
        Join-Path -Path (NewVmsAppDataPath) -ChildPath 'credentials.xml'
    }
}
function HandleValidateResultException {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [System.Management.Automation.ErrorRecord]
        $ErrorRecord,

        [Parameter()]
        [object]
        $TargetObject,

        [Parameter()]
        [string]
        $ItemName = 'Not set'
    )

    process {
        # This function makes it easier to understand the reason for the validation error, but it hides the original
        # ScriptStackTrace so the error looks like it originates in the original "catch" block instead of the line
        # inside the "try" block triggering the exception. Writing the original stacktrace out to the debug stream
        # is a compromise for being unable to throw the original exception and stacktrace with a better exception
        # message.
        Write-Debug -Message "Original ScriptStackTrace:`n$($ErrorRecord.ScriptStackTrace)"

        $lastCommand = (Get-PSCallStack)[1]
        $origin = $lastCommand.Command
        $exception = $ErrorRecord.Exception
        $validateResult = $exception.ValidateResult
        if (-not $MyInvocation.BoundParameters.ContainsKey('ItemName') -and -not [string]::IsNullOrWhiteSpace($validateResult.ResultItem.DisplayName)) {
            $ItemName = $validateResult.ResultItem.DisplayName
        }
        foreach ($errorResult in $validateResult.ErrorResults) {
            $errorParams = @{
                Message           = '{0}: Invalid value for property "{1}" on {2}. ErrorText = "{3}". Origin = {4}' -f $errorResult.ErrorTextId, $errorResult.ErrorProperty, $ItemName, $errorResult.ErrorText, $origin
                Exception         = $Exception
                Category          = 'InvalidData'
                RecommendedAction = 'Review the invalid property value and try again.'
            }
            if ($TargetObject) {
                $errorParams.TargetObject = $TargetObject
            }
            Write-Error @errorParams
        }
    }
}
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 NewVmsAppDataPath {
    [CmdletBinding()]
    [OutputType([string])]
    param()
    
    process {
        $appDataRoot = Join-Path -Path $env:LOCALAPPDATA -ChildPath 'MilestonePSTools\'
        (New-Item -Path $appDataRoot -ItemType Directory -Force).FullName
    }
}
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 Show-DeprecationWarning {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [System.Management.Automation.InvocationInfo]
        $InvocationInfo
    )

    process {
        $oldName = $InvocationInfo.InvocationName
        if ($script:Deprecations.ContainsKey($oldName)) {
            $newName = $script:deprecations[$oldName]
            Write-Warning "The '$oldName' cmdlet is deprecated. To minimize the risk of being impacted by a breaking change in the future, please use '$newName' instead."
            $script:Deprecations.Remove($oldName)
        }
    }
}
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')]
    [RequiresVmsConnection()]
    param (
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateVmsItemType('CameraGroup', 'MicrophoneGroup', 'SpeakerGroup', 'MetadataGroup', 'InputEventGroup', 'OutputGroup')]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $Group,

        [Parameter(Mandatory, Position = 0, ParameterSetName = 'ByObject')]
        [ValidateVmsItemType('Camera', 'Microphone', 'Speaker', 'Metadata', 'InputEvent', 'Output')]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem[]]
        $Device,

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

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $dirty = $false
        $groupItemType = ($Group | Split-VmsConfigItemPath -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-VmsHardware {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.Hardware])]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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-VmsSite).Name)" -Cleanup | Foreach-Object {
            $vmsTask = [VmsTaskResult]$_
            if ($vmsTask.State -eq [VmsTaskState]::Success) {
                $hardwareId = $vmsTask | Split-VmsConfigItemPath -Id
                $newHardware = Get-VmsHardware -Id $hardwareId
                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-VmsSite).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-VmsLoginProviderClaim {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('22.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [LoginProviderTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.LoginProvider]
        $LoginProvider,

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

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

        [Parameter()]
        [switch]
        $CaseSensitive
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        if ($DisplayName.Count -gt 0 -and $DisplayName.Count -ne $Name.Count) {
            Write-Error "Number of claim names does not match the number of display names. When providing display names for claims, the number of DisplayName values must match the number of Name values."
            return
        }
        try {
            for ($index = 0; $index -lt $Name.Count; $index++) {
                $claimName = $Name[$index]
                $claimDisplayName = $Name[$index]
                if ($DisplayName.Count -gt 0) {
                    $claimDisplayName = $DisplayName[$index]
                }
                if ($PSCmdlet.ShouldProcess("Login provider '$($LoginProvider.Name)'", "Add claim '$claimName'")) {
                    $null = $LoginProvider.RegisteredClaimFolder.AddRegisteredClaim($claimName, $claimDisplayName, $CaseSensitive)
                }
            }
        } catch {
            Write-Error -Message $_.Exception.Message -TargetObject $LoginProvider
        }
    }
}

Register-ArgumentCompleter -CommandName Add-VmsLoginProviderClaim -ParameterName LoginProvider -ScriptBlock {
    $values = (Get-VmsLoginProvider).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Clear-VmsSiteInfo {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.2')]
    param (
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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-VmsSite).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)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    [OutputType([VideoOS.Platform.ConfigurationItems.View])]
    param (
        [Parameter(Mandatory, ValueFromPipeline, Position = 1)]
        [VideoOS.Platform.ConfigurationItems.View[]]
        $View,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection()]
    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 {
        Assert-VmsRequirementsMet
        $assembly = [System.Reflection.Assembly]::GetAssembly([VideoOS.Platform.ConfigurationItems.Hardware])
        $serverId = (Get-VmsSite -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()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    [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-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    [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-VmsRequirementsMet
    }

    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])]
    [RequiresVmsConnection()]
    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 {
        Assert-VmsRequirementsMet
        $firstRun = $true
        try {
            Get-VmsSite -ErrorAction Stop | Select-VmsSite

            $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()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.2')]
    [OutputType([System.IO.FileInfo])]
    param (
        [Parameter(Mandatory)]
        [string]
        $Path,

        [Parameter()]
        [switch]
        $Force,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion(21.1)]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.ViewGroup]
        $ViewGroup,

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

        [Parameter()]
        [switch]
        $Force
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection()]
    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 = @{}
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.2')]
    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 {
        Assert-VmsRequirementsMet
        $loginSettings = Get-LoginSettings
    }

    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()]
    [RequiresVmsConnection($false)]
    param()

    begin {
        Assert-VmsRequirementsMet
        $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')]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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-VmsSite
                $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()]
    [RequiresVmsConnection($false)]
    param()

    begin {
        Assert-VmsRequirementsMet
        $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-VmsBasicUser {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.BasicUser])]
    [RequiresVmsConnection()]
    param (
        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $Name,

        [Parameter()]
        [ValidateSet('Enabled', 'LockedOutByAdmin', 'LockedOutBySystem')]
        [string]
        $Status,

        [Parameter()]
        [switch]
        $External
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $matchFound = $false
        foreach ($user in (Get-VmsManagementServer).BasicUserFolder.BasicUsers){
            if ($MyInvocation.BoundParameters.ContainsKey('Status') -and $user.Status -ne $Status) {
                continue
            }

            if ($MyInvocation.BoundParameters.ContainsKey('External') -and $user.IsExternal -ne $External) {
                continue
            }

            if ($MyInvocation.BoundParameters.ContainsKey('Name') -and $user.Name -ne $Name) {
                continue
            }
            $matchFound = $true
            $user
        }
        if ($MyInvocation.BoundParameters.ContainsKey('Name') -and -not $matchFound) {
            Write-Error "No basic user found matching the name '$Name'"
        }
    }
}

function Get-VmsBasicUserClaim {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.ClaimChildItem])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('22.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [VideoOS.Platform.ConfigurationItems.BasicUser[]]
        $InputObject
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        foreach ($user in $InputObject) {
            $user.ClaimFolder.ClaimChildItems | ForEach-Object {
                $_
            }
        }
    }
}
function Get-VmsCamera {
    [CmdletBinding(DefaultParameterSetName = 'BySearch')]
    [Alias('Get-Camera')]
    [OutputType([VideoOS.Platform.ConfigurationItems.Camera])]
    [RequiresVmsConnection()]
    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-VmsRequirementsMet
        Show-DeprecationWarning $MyInvocation
        $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 -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
        }

        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-VmsSite
                $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-VmsSite).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-VmsSite).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-VmsHardware | 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])]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Camera[]]
        $Camera,

        [Parameter()]
        [switch]
        $RawValues,

        [Parameter()]
        [switch]
        $ValueTypeInfo
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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])]
    [RequiresVmsConnection()]
    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(ParameterSetName = 'PlaybackDefault')]
        [switch]
        $PlaybackDefault,

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

        [Parameter(ParameterSetName = 'RecordingTrack')]
        [ValidateSet('Primary', 'Secondary', 'None')]
        [string]
        $RecordingTrack,

        [Parameter()]
        [switch]
        $RawValues
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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 ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Recorded') -and $Recorded -ne $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
                }
                $isRecorded = $streamUsage.Record -or -not [string]::IsNullOrWhiteSpace($streamUsage.RecordTo)
                if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Recorded') -and $Recorded -ne $streamUsage.Record) {
                    continue
                }

                if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('RecordingTrack')) {
                    if ($RecordingTrack -eq 'Primary' -and -not $streamUsage.Record) {
                        continue
                    } elseif ($RecordingTrack -eq 'Secondary' -and $streamUsage.RecordTo -ne '84fff8b9-8cd1-46b2-a451-c4a87d4cbbb0') {
                        continue
                    } elseif ($RecordingTrack -eq 'None' -and ($streamUsage.Record -or -not [string]::IsNullOrEmpty($streamUsage.RecordTo))) {
                        continue
                    }
                }

                if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('PlaybackDefault') -and (($streamUsage.RecordToValues.Count -eq 0 -and $streamUsage.Record -ne $PlaybackDefault) -or ($streamUsage.RecordToValues.Count -gt 0 -and $streamUsage.DefaultPlayback -ne $PlaybackDefault))) {
                    continue
                }

                if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Enabled') -and $streamUsages -and $Enabled -eq ($null -eq $streamUsage)) {
                    continue
                }

                if ($MyInvocation.BoundParameters.ContainsKey('Name') -and $stream.DisplayName -notlike $Name) {
                    continue
                }

                $streamConfig = [VmsCameraStreamConfig]@{
                    Name         = $stream.DisplayName
                    Camera       = $cam
                    UseRawValues = $RawValues
                }
                $streamConfig.Update()
                $streamConfig
            }
        }
    }
}
function Get-VmsConnectionString {
    [CmdletBinding()]
    [Alias('Get-ConnectionString')]
    [OutputType([string])]
    [RequiresVmsConnection($false)]
    param (
        [Parameter(Position = 0)]
        [string]
        $Component = 'ManagementServer'
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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')]
    [RequiresVmsConnection()]
    param (
        [Parameter(ValueFromPipeline, ParameterSetName = 'ByName')]
        [ValidateVmsItemType('CameraGroup', 'MicrophoneGroup', 'SpeakerGroup', 'MetadataGroup', 'InputEventGroup', 'OutputGroup')]
        [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 {
        Assert-VmsRequirementsMet
        $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' {
                $subGroups = $rootGroup."$($adjustedType)GroupFolder"."$($adjustedType)Groups"
                $subGroups | Where-Object Name -like $Name | Foreach-Object {
                    if ($null -eq $_) { return }
                    $matchFound = $true
                    $_
                    if ($Recurse) {
                        $_ | Get-VmsDeviceGroup -Type $Type -Recurse
                    }
                }
            }

            'ByPath' {
                foreach ($groupPath in $Path) {
                    $pathPrefixPattern = '^/(?<type>(Camera|Microphone|Speaker|Metadata|Input|Output))(Event)?GroupFolder'
                    if ($groupPath -match $pathPrefixPattern) {
                        $pathPrefix = $groupPath -replace '^/(Camera|Microphone|Speaker|Metadata|Input|Output)(Event)?GroupFolder.*', '$1'
                        if ($pathPrefix -ne $Type) {
                            if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Type')) {
                                throw "The device group prefix '$pathPrefix' does not match the specified device group type '$Type'. Either remove the prefix from the path, or do not specify a value for the Type parameter."
                            } else {
                                Write-Verbose "Device type '$pathPrefix' determined from the provided path."
                                $Type = $pathPrefix
                            }
                        }
                    }
                    $params = @{
                        Type        = $Type
                        ErrorAction = 'SilentlyContinue'
                    }
                    $pathInterrupted = $false
                    $groupPath = $groupPath -replace '^/(Camera|Microphone|Speaker|Metadata|InputEvent|Output)GroupFolder', ''
                    $pathParts = $groupPath | 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 -Type $Type -Recurse
                        }
                    }
                    if ($null -eq $pathParts -and $Recurse) {
                        Get-VmsDeviceGroup -Type $Type -Recurse
                    }
                }
            }
        }

        if (-not $matchFound -and -not [management.automation.wildcardpattern]::ContainsWildcardCharacters($Name)) {
            Write-Error "No $Type group found with the name '$Name'"
        }
    }
}
function Get-VmsDeviceGroupMember {
    [CmdletBinding()]
    [RequiresVmsConnection()]
    param (
        [Parameter(ValueFromPipeline)]
        [ValidateVmsItemType('CameraGroup', 'MicrophoneGroup', 'MetadataGroup', 'SpeakerGroup', 'InputEventGroup', 'OutputGroup')]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $Group,

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

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $deviceType = ($Group | Split-VmsConfigItemPath -ItemType) -replace 'Group$', ''
        $Group."$($deviceType)Folder"."$($deviceType)s" | ForEach-Object {
            if ($_.Enabled -and $EnableFilter -eq 'Disabled') {
                return
            }
            if (-not $_.Enabled -and $EnableFilter -eq 'Enabled') {
                return
            }
            $_
        }
    }
}
function Get-VmsDeviceStatus {
    [CmdletBinding()]
    [OutputType([VmsStreamDeviceStatus])]
    [RequiresVmsConnection()]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Id')]
        [guid[]]
        $RecordingServerId,

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

    begin {
        Assert-VmsRequirementsMet
        $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-VmsHardwareDriver {
    [CmdletBinding(DefaultParameterSetName = 'Hardware')]
    [OutputType([VideoOS.Platform.ConfigurationItems.HardwareDriver])]
    [Alias('Get-HardwareDriver')]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'RecordingServer')]
        [RecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.RecordingServer[]]
        $RecordingServer,

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

    begin {
        Assert-VmsRequirementsMet
        Show-DeprecationWarning $MyInvocation
        $driversByRecorder = @{}
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'RecordingServer' {
                foreach ($rec in $RecordingServer) {
                    foreach ($driver in $rec.HardwareDriverFolder.HardwareDrivers | Sort-Object DriverType) {
                        $driver
                    }
                }
            }
            'Hardware' {
                foreach ($hw in $Hardware) {
                    if (-not $driversByRecorder.ContainsKey($hw.ParentItemPath)) {
                        $driversByRecorder[$hw.ParentItemPath] = @{}
                        $rec = [VideoOS.Platform.ConfigurationItems.RecordingServer]::new($hw.ServerId, $hw.ParentItemPath)
                        $rec.HardwareDriverFolder.HardwareDrivers | ForEach-Object {
                            $driversByRecorder[$hw.ParentItemPath][$_.Path] = $_
                        }
                    }
                    $driver = $driversByRecorder[$hw.ParentItemPath][$hw.HardwareDriverPath]
                    if ($null -eq $driver) {
                        Write-Error "HardwareDriver '$($hw.HardwareDriverPath)' for hardware '$($hw.Name)' not found on the parent recording server."
                        continue
                    }
                    $driver
                }
            }
            Default {
                throw "Support for ParameterSetName '$_' not implemented."
            }
        }
    }

    end {
        $driversByRecorder.Clear()
    }
}

Register-ArgumentCompleter -CommandName Get-VmsHardwareDriver -ParameterName RecordingServer -ScriptBlock {
    $values = (Get-VmsRecordingServer).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsHardwarePassword {
    [CmdletBinding()]
    [OutputType([string])]
    [Alias('Get-HardwarePassword')]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Hardware]
        $Hardware
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        try {
            $serverTask = $Hardware.ReadPasswordHardware()
            if ($serverTask.State -ne [VideoOS.Platform.ConfigurationItems.StateEnum]::Success) {
                Write-Error -Message "ReadPasswordHardware error: $(t.ErrorText)" -TargetObject $Hardware
                return
            }
            $serverTask.GetProperty('Password')
        } catch {
            Write-Error -Message $_.Exception.Message -Exception $_.Exception -TargetObject $Hardware
        }
    }
}
function Get-VmsLoginProvider {
    [CmdletBinding()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('22.1')]
    [OutputType([VideoOS.Platform.ConfigurationItems.LoginProvider])]
    param (
        [Parameter(Position = 0)]
        [string]
        $Name
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {

        if ($MyInvocation.BoundParameters.ContainsKey('Name')) {
            $loginProviders = (Get-VmsManagementServer).LoginProviderFolder.LoginProviders | Where-Object Name -EQ $Name
        } else {
            $loginProviders = (Get-VmsManagementServer).LoginProviderFolder.LoginProviders | ForEach-Object { $_ }
        }
        if ($loginProviders) {
            $loginProviders
        } elseif ($MyInvocation.BoundParameters.ContainsKey('Name')) {
            Write-Error 'No matching login provider found.'
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsLoginProvider -ParameterName Name -ScriptBlock {
    $values = (Get-VmsLoginProvider).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsLoginProviderClaim {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.RegisteredClaim])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('22.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [LoginProviderTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.LoginProvider]
        $LoginProvider,

        [Parameter()]
        [string]
        $Name
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $LoginProvider.RegisteredClaimFolder.RegisteredClaims | Foreach-Object {
            if ($MyInvocation.BoundParameters.ContainsKey('Name') -and $_.Name -ne $Name) {
                return
            }
            $_
        }
    }
}
function Get-VmsRecordingServer {
    [CmdletBinding(DefaultParameterSetName = 'ByName')]
    [Alias('Get-RecordingServer')]
    [OutputType([VideoOS.Platform.ConfigurationItems.RecordingServer])]
    [RequiresVmsConnection()]
    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 = '*'
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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-VmsSiteInfo {
    [CmdletBinding()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.2')]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateScript({ ValidateSiteInfoTagName @args })]
        [SupportsWildcards()]
        [string]
        $Property = '*'
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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])]
    [RequiresVmsConnection()]
    param(
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
        [StorageNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Storage[]]
        $Storage
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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(DefaultParameterSetName = 'CurrentSite')]
    [OutputType([string])]
    [Alias('Get-Token')]
    [RequiresVmsConnection()]
    param (
        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'ServerId')]
        [VideoOS.Platform.ServerId]
        $ServerId,

        [Parameter(ValueFromPipeline, ParameterSetName = 'Site')]
        [VideoOS.Platform.Item]
        $Site
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        try {
            switch ($PSCmdlet.ParameterSetName) {
                'CurrentSite' {
                    [VideoOS.Platform.Login.LoginSettingsCache]::GetLoginSettings((Get-VmsSite).FQID).Token
                }

                'ServerId' {
                    [VideoOS.Platform.Login.LoginSettingsCache]::GetLoginSettings($ServerId).Token
                }

                'Site' {
                    [VideoOS.Platform.Login.LoginSettingsCache]::GetLoginSettings($Site.FQID).Token
                }

                Default {
                    throw "ParameterSet '$_' not implemented."
                }
            }
        } catch {
            Write-Error -ErrorRecord $_
        }
    }
}
function Get-VmsView {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    [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-VmsRequirementsMet
    }

    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-VmsSite).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()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    [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-VmsRequirementsMet
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ById') {
            try {
                $vg = [VideoOS.Platform.ConfigurationItems.ViewGroup]::new((Get-VmsSite).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()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    [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-VmsRequirementsMet
    }

    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)]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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-VmsSite -ErrorAction Stop | Select-VmsSite
        }
        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) {
                        if ($result.State -eq 'Error') {
                            Write-Error -Message "ErrorCode $($result.ErrorCode): $($result.ErrorText)" -TargetObject $_
                            continue
                        }
                        $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-VmsHardware | 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))
                        })
                    }
                }
            }

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

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

            $recorderByPath = @{}
            $storageCache = @{}
            if ($PSCmdlet.ShouldProcess("Milestone XProtect site '$((Get-VmsSite).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()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.2')]
    [OutputType([VideoOS.Platform.ConfigurationItems.LicenseInformation])]
    param (
        [Parameter(Mandatory)]
        [string]
        $Path
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    [OutputType([VideoOS.Platform.ConfigurationItems.ViewGroup])]
    param(
        [Parameter(Mandatory)]
        [string]
        $Path,

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

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

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.2')]
    [OutputType([VideoOS.Platform.ConfigurationItems.LicenseInformation])]
    param (
        [Parameter(Mandatory)]
        [pscredential]
        $Credential,

        [Parameter()]
        [switch]
        $EnableAutoActivation
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        try {
            $ms = Get-VmsManagementServer
            $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])]
    [RequiresVmsConnection($false)]
    param (
        # Specifies a device group path in unix directory form with forward-slashes as separators.
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
        [string[]]
        $PathParts
    )

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

    process {

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

    end {
        $sb.ToString()
    }
}
function New-VmsBasicUser {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.BasicUser])]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [string]
        $Name,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [SecureStringTransformAttribute()]
        [securestring]
        $Password,

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

        [Parameter(ValueFromPipelineByPropertyName)]
        [BoolTransformAttribute()]
        [bool]
        $CanChangePassword = $true,

        [Parameter(ValueFromPipelineByPropertyName)]
        [BoolTransformAttribute()]
        [bool]
        $ForcePasswordChange,

        [Parameter(ValueFromPipelineByPropertyName)]
        [ValidateSet('Enabled', 'LockedOutByAdmin')]
        [string]
        $Status = 'Enabled'
    )

    begin {
        Assert-VmsRequirementsMet
        $ms = Get-VmsManagementServer
    }

    process {
        try {
            $result = $ms.BasicUserFolder.AddBasicUser($Name, $Description, $CanChangePassword, $ForcePasswordChange, $Password, $Status)
            [VideoOS.Platform.ConfigurationItems.BasicUser]::new($ms.ServerId, $result.Path)
        } catch {
            Write-Error -ErrorRecord $_
        }
    }
}
function New-VmsDeviceGroup {
    [CmdletBinding()]
    [Alias('Add-DeviceGroup')]
    [OutputType([VideoOS.Platform.ConfigurationItems.IConfigurationItem])]
    [RequiresVmsConnection()]
    param (
        [Parameter(ValueFromPipeline, Position = 0, ParameterSetName = 'ByName')]
        [ValidateVmsItemType('CameraGroup', 'MicrophoneGroup', 'SpeakerGroup', 'MetadataGroup', 'InputEventGroup', 'OutputGroup')]
        [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 {
        Assert-VmsRequirementsMet
        $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) {
                    try {
                        $skip = 0
                        $pathPrefixPattern = '^/(?<type>(Camera|Microphone|Speaker|Metadata|Input|Output))(Event)?GroupFolder'
                        if ($p -match $pathPrefixPattern) {
                            $pathPrefix = $p -replace '^/(Camera|Microphone|Speaker|Metadata|Input|Output)(Event)?GroupFolder.*', '$1'
                            if ($pathPrefix -ne $params.Type) {
                                if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Type')) {
                                    throw "The device group prefix '$pathPrefix' does not match the specified device group type '$Type'. Either remove the prefix from the path, or do not specify a value for the Type parameter."
                                } else {
                                    Write-Verbose "Device type '$pathPrefix' determined from the provided path."
                                    $params.Type = $pathPrefix
                                }
                            }
                            $skip = 1
                        }
                        $p | Split-VmsDeviceGroupPath | Select-Object -Skip $skip | ForEach-Object {
                            $params.Remove('Name')
                            $group = Get-VmsDeviceGroup @params -Name ($_ -replace '([\*\?\[\]])', '`$1') -ErrorAction SilentlyContinue
                            $params.Name = $_
                            if ($null -eq $group) {
                                $group = New-VmsDeviceGroup @params -ErrorAction Stop
                            }
                            $params.ParentGroup = $group
                        }
                        if (-not [string]::IsNullOrWhiteSpace($Description)) {
                            $group.Description = $Description
                            $group.Save()
                        }
                        $group
                    } catch {
                        Write-Error -ErrorRecord $_
                    }
                }
            }
            Default {
                throw "Parameter set '$_' not implemented."
            }
        }
    }
}
function New-VmsLoginProvider {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.LoginProvider])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('22.1')]
    param (
        [Parameter(Mandatory)]
        [string]
        $Name,

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

        [Parameter(Mandatory)]
        [SecureStringTransformAttribute()]
        [securestring]
        $ClientSecret,

        [Parameter()]
        [string]
        $CallbackPath = '/signin-oidc',

        [Parameter(Mandatory)]
        [uri]
        $Authority,

        [Parameter()]
        [string]
        $UserNameClaim,

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

        [Parameter()]
        [bool]
        $PromptForLogin = $true,

        [Parameter()]
        [bool]
        $Enabled = $true
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        try {
            $credential = [pscredential]::new($ClientId, $ClientSecret)
            $folder = (Get-VmsManagementServer).LoginProviderFolder
            $serverTask = $folder.AddLoginProvider([guid]::Empty, $Name, $ClientId, $credential.GetNetworkCredential().Password, $CallbackPath, $Authority, $UserNameClaim, $Scopes, $PromptForLogin, $Enabled)
            $loginProvider = Get-VmsLoginProvider | Where-Object Path -eq $serverTask.Path
            if ($null -ne $loginProvider) {
                $loginProvider
            }
        } catch {
            Write-Error -Message $_.Exception.Message -Exception $_.Exception -TargetObject $serverTask
        }
    }
}
function New-VmsView {
    [CmdletBinding(DefaultParameterSetName = 'Default')]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    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-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    [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-VmsRequirementsMet
    }

    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-VmsBasicUser {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.BasicUser[]]
        $InputObject
    )

    begin {
        Assert-VmsRequirementsMet
        $folder = (Get-VmsManagementServer).BasicUserFolder
    }

    process {
        foreach ($user in $InputObject) {
            $target = "Basic user $($InputObject.Name)"
            if ($user.IsExternal) {
                $target += " <External IDP>"
            }
            if ($PSCmdlet.ShouldProcess($target, "Remove")) {
                try {
                    $null = $folder.RemoveBasicUser($user.Path)
                } catch {
                    Write-Error -Message $_.Exception.Message -TargetObject $user
                }
            }
        }
    }
}
function Remove-VmsDeviceGroup {
    [CmdletBinding(ConfirmImpact = 'High', SupportsShouldProcess)]
    [Alias('Remove-DeviceGroup')]
    [RequiresVmsConnection()]
    param (
        [Parameter(ValueFromPipeline)]
        [ValidateVmsItemType('CameraGroup', 'MicrophoneGroup', 'MetadataGroup', 'SpeakerGroup', 'InputEventGroup', 'OutputGroup')]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem[]]
        $Group,

        [Parameter()]
        [switch]
        $Recurse
    )

    begin {
        Assert-VmsRequirementsMet
        $cacheToClear = @{}
    }

    process {
        foreach ($g in $Group) {
            $itemType = $g | Split-VmsConfigItemPath -ItemType
            $target = "$itemType '$($g.Name)'"
            $action = "Delete"
            if ($Recurse) {
                $target += " and all group members"
            }
            if ($PSCmdlet.ShouldProcess($target, $action)) {
                try {
                    $parentFolder = Get-ConfigurationItem -Path $g.ParentPath
                    $invokeInfo = $parentFolder | Invoke-Method -MethodId RemoveDeviceGroup
                    if ($Recurse -and ($prop = $invokeInfo.Properties | Where-Object Key -eq 'RemoveMembers')) {
                        $prop.Value = $Recurse.ToString()
                    } elseif ($Recurse) {
                        # Versions around 2019 and older apparently didn't have a "RemoveMembers" option for recursively deleting device groups.
                        $members = $g | Get-VmsDeviceGroupMember -EnableFilter All
                        if ($members.Count -gt 0) {
                            $g | Remove-VmsDeviceGroupMember -Device $members -Confirm:$false
                        }
                        $g | Get-VmsDeviceGroup | Remove-VmsDeviceGroup -Recurse -Confirm:$false
                    }

                    ($invokeInfo.Properties | Where-Object Key -eq 'ItemSelection').Value = $g.Path
                    $null = $invokeInfo | Invoke-Method -MethodId RemoveDeviceGroup -ErrorAction Stop
                    $cacheToClear[$itemType] = $null
                } catch {
                    Write-Error -ErrorRecord $_
                }
            }
        }

    }

    end {
        $cacheToClear.Keys | Foreach-Object {
            Write-Verbose "Clearing $_ cache"
            (Get-VmsManagementServer)."$($_)Folder".ClearChildrenCache()
        }
    }
}
function Remove-VmsDeviceGroupMember {
    [CmdletBinding(ConfirmImpact = 'High', SupportsShouldProcess)]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [ValidateVmsItemType('CameraGroup', 'MicrophoneGroup', 'MetadataGroup', 'SpeakerGroup', 'InputEventGroup', 'OutputGroup')]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $Group,

        [Parameter(Mandatory, Position = 0, ParameterSetName = 'ByObject')]
        [ValidateVmsItemType('Camera', 'Microphone', 'Metadata', 'Speaker', 'InputEvent', 'Output')]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem[]]
        $Device,

        [Parameter(Mandatory, Position = 1, ParameterSetName = 'ById')]
        [guid[]]
        $DeviceId
    )
    
    begin {
        Assert-VmsRequirementsMet
    }
    
    process {
        $groupItemType = ($Group | Split-VmsConfigItemPath -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-VmsHardware {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')]
    [Alias('Remove-Hardware')]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Hardware[]]
        $Hardware
    )

    begin {
        Assert-VmsRequirementsMet
        $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-VmsLoginProvider {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('22.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.LoginProvider]
        $LoginProvider,

        [Parameter()]
        [switch]
        $Force
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        if ($PSCmdlet.ShouldProcess("Login provider '$($LoginProvider.Name)'", 'Remove')) {
            if ($Force) {
                # Disable the login provider to ensure no external users login
                # and generate a new external basic user between the time the users
                # are removed and the provider is deleted.
                $LoginProvider | Set-VmsLoginProvider -Enabled $false -ErrorAction Stop -Verbose:($VerbosePreference -eq 'Continue')

                # The basic user folder may be cached already, and there may be
                # new external users on the VMS that are not present in the cache.
                # By clearing the cache we ensure that the next step removes all
                # external users.
                (Get-VmsManagementServer).BasicUserFolder.ClearChildrenCache()

                # Remove all basic users with claims associated with this login provider
                Get-VmsBasicUser -External | Where-Object {
                    $_.ClaimFolder.ClaimChildItems.ClaimProvider -contains $LoginProvider.Id
                } | Remove-VmsBasicUser -ErrorAction Stop -Verbose:($VerbosePreference -eq 'Continue')

                # Remove all claims associated with this login provider from all roles
                foreach ($role in Get-VmsRole) {
                    $claims = $role | Get-VmsRoleClaim | Where-Object ClaimProvider -EQ $LoginProvider.Id
                    if ($claims.Count -gt 0) {
                        $role | Remove-VmsRoleClaim -ClaimName $claims.ClaimName -ErrorAction Stop -Verbose:($VerbosePreference -eq 'Continue')
                    }
                }

                # Remove all claims registered on this login provider
                $LoginProvider | Remove-VmsLoginProviderClaim -All -ErrorAction Stop
            }
            $null = (Get-VmsManagementServer).LoginProviderFolder.RemoveLoginProvider($LoginProvider.Path)
        }
    }
}
function Remove-VmsLoginProviderClaim {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('22.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'All')]
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Name')]
        [LoginProviderTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.LoginProvider]
        $LoginProvider,

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

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

        [Parameter()]
        [switch]
        $Force
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        if ($Force) {
            Get-VmsRole | Foreach-Object {
                $currentRole = $_
                $claims = $currentRole | Get-VmsRoleClaim -LoginProvider $LoginProvider | Where-Object {
                    $All -or $_.ClaimName -eq $ClaimName
                }
                if ($claims.Count -eq 0) {
                    return
                }
                $currentRole | Remove-VmsRoleClaim -ClaimName $claims.ClaimName
            }
        }
        $folder = $LoginProvider.RegisteredClaimFolder
        $LoginProvider | Get-VmsLoginProviderClaim | Foreach-Object {
            if (-not [string]::IsNullOrWhiteSpace($ClaimName) -and $_.Name -notlike $ClaimName) {
                return
            }
            if ($PSCmdlet.ShouldProcess("Registered claim '$($_.DisplayName)'", "Remove")) {
                $null = $folder.RemoveRegisteredClaim($_.Path)
            }
        }
    }
}

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

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

    begin {
        Assert-VmsRequirementsMet
    }

    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')]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.ViewGroup[]]
        $ViewGroup,

        [Parameter()]
        [switch]
        $Recurse
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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 {
    [CmdletBinding()]
    [RequiresVmsConnection()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [Alias('DeviceGroup')]
        [ValidateVmsItemType('CameraGroup', 'MicrophoneGroup', 'SpeakerGroup', 'MetadataGroup', 'InputEventGroup', 'OutputGroup')]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $Group,

        [Parameter()]
        [switch]
        $NoTypePrefix
    )

    begin {
        Assert-VmsRequirementsMet
        $ctor = $null
        $sb = [text.stringbuilder]::new()
    }

    process {
        if ($null -eq $ctor -or $ctor.ReflectedType -ne $Group.GetType()) {
            $ctor = $Group.GetType().GetConstructor(@([videoos.platform.serverid], [string]))
        }
        try {
            $current = $Group
            $null = $sb.Clear().Insert(0, "/$($current.Name -replace '(?<!`)/', '`/')")
            while ($current.ParentItemPath -ne '/') {
                $current = $ctor.Invoke(@($current.ServerId, $current.ParentItemPath))
                $null = $sb.Insert(0, "/$($current.Name -replace '(?<!`)/', '`/')")
            }
            if (-not $NoTypePrefix) {
                $null = $sb.Insert(0, $current.ParentPath)
            }
            $sb.ToString()
        } catch {
            throw
        }
    }
}
function Set-VmsBasicUser {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.BasicUser])]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.BasicUser]
        $BasicUser,

        [Parameter()]
        [SecureStringTransformAttribute()]
        [securestring]
        $Password,

        [Parameter()]
        [string]
        $Description,

        [Parameter()]
        [BoolTransformAttribute()]
        [bool]
        $CanChangePassword,

        [Parameter()]
        [BoolTransformAttribute()]
        [bool]
        $ForcePasswordCHange,

        [Parameter()]
        [ValidateSet('Enabled', 'LockedOutByAdmin')]
        [string]
        $Status,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        try {
            if ($PSCmdlet.ShouldProcess("Basic user '$($BasicUser.Name)'", "Update")) {
                $dirty = $false
                $dirtyPassword = $false
                $initialName = $BasicUser.Name
                foreach ($key in @(($MyInvocation.BoundParameters.GetEnumerator() | Where-Object Key -in $BasicUser.GetPropertyKeys()).Key) + @('Password')) {
                    $newValue = (Get-Variable -Name $key).Value
                    if ($MyInvocation.BoundParameters.ContainsKey('Password') -and $key -eq 'Password') {
                        if ($BasicUser.IsExternal -or -not $BasicUser.CanChangePassword) {
                            Write-Error "Password can not be changed for '$initialName'. IsExternal = $($BasicUser.IsExternal), CanChangePassword = $($BasicUser.CanChangePassword)" -TargetObject $BasicUser
                        } else {
                            Write-Verbose "Updating $key on '$initialName'"
                            $null = $BasicUser.ChangePasswordBasicUser($Password)
                            $dirtyPassword = $true
                        }
                    } elseif ($BasicUser.$key -cne $newValue) {
                        Write-Verbose "Updating $key on '$initialName'"
                        $BasicUser.$key = $newValue
                        $dirty = $true
                    }
                }
                if ($dirty) {
                    $BasicUser.Save()
                } elseif (-not $dirtyPassword) {
                    Write-Verbose "No changes were made to '$initialName'."
                }
            }

            if ($PassThru) {
                $BasicUser
            }
        } catch {
            Write-Error -Message $_.Exception.Message -TargetObject $BasicUser
        }
    }
}
function Set-VmsCamera {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([VideoOS.Platform.ConfigurationItems.Camera])]
    [RequiresVmsConnection()]
    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 {
        Assert-VmsRequirementsMet
        $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-VmsSite).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')]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Camera[]]
        $Camera,

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

    begin {
        Assert-VmsRequirementsMet
    }

    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')]
    [RequiresVmsConnection()]
    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')]
        [ValidateSet('Always', 'Never', 'WhenNeeded')]
        [string]
        $LiveMode,

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

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

        [Parameter(ParameterSetName = 'AddOrUpdateStream')]
        [ValidateSet('Primary', 'Secondary', 'None')]
        [string]
        $RecordingTrack,

        [Parameter(ParameterSetName = 'AddOrUpdateStream')]
        [ValidateVmsVersion('23.2')]
        [ValidateVmsFeature('MultistreamRecording')]
        [switch]
        $PlaybackDefault,

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

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

    begin {
        Assert-VmsRequirementsMet

        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Recorded') -and $Recorded) {
            Write-Warning "The 'Recorded' switch parameter is deprecated with MilestonePSTools version 2023 R2 and later due to the added support for adaptive playback. For compatibility reasons, the '-Recorded' switch has the same meaning as '-RecordingTrack Primary -PlaybackDefault' unless one or both of these parameters were also specified."
            if (-not $PSCmdlet.MyInvocation.BoundParameters.ContainsKey('RecordingTrack')) {
                Write-Verbose "Setting RecordingTrack parameter to 'Primary'"
                $PSCmdlet.MyInvocation.BoundParameters['RecordingTrack'] = $RecordingTrack = 'Primary'
            }
            if (-not $PSCmdlet.MyInvocation.BoundParameters.ContainsKey('PlaybackDefault')) {
                Write-Verbose "Setting PlaybackDefault parameter to `$true"
                $PSCmdlet.MyInvocation.BoundParameters['PlaybackDefault'] = $PlaybackDefault = [switch]::new($true)
            }
            $null = $PSCmdlet.MyInvocation.BoundParameters.Remove('Recorded')
            Remove-Variable -Name 'Recorded'
        }
        $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[$s.Name]
            }

            if ($PSCmdlet.ParameterSetName -eq 'RemoveStream' -and $null -ne $streamUsageChildItem -and $PSCmdlet.ShouldProcess($s.Camera.Name, "Disabling stream '$($s.Name)'")) {
                if ($streamUsages.StreamUsageChildItems.Count -eq 1) {
                    Write-Error "Stream $($s.Name) cannot be removed because it is the only enabled stream."
                } else {
                    $result = $streamUsages.RemoveStream($streamUsageChildItem.StreamReferenceId)
                    if ($result.State -eq 'Success') {
                        $s.Update()
                        $streamUsages = $s.Camera.StreamFolder.Streams[0]
                        $itemCache[$streamUsages.Path] = $streamUsages
                    } else {
                        Write-Error $result.ErrorText
                    }
                }
            } elseif ($PSCmdlet.ParameterSetName -eq 'AddOrUpdateStream') {
                $dirtyStreamUsages = $false
                $parametersRequiringStreamUsage = @('DisplayName', 'LiveDefault', 'LiveMode', 'PlaybackDefault', 'Recorded', 'RecordingTrack', 'UseEdge')
                if ($null -eq $streamUsageChildItem -and ($PSCmdlet.MyInvocation.BoundParameters.Keys | Where-Object { $_ -in $parametersRequiringStreamUsage } ) -and $PSCmdlet.ShouldProcess($s.Camera.Name, 'Adding a new stream usage')) {
                    try {
                        $result = $streamUsages.AddStream()
                        if ($result.State -ne 'Success') {
                            throw $result.ErrorText
                        }
                        $s.Update()
                        $streamUsages = $s.Camera.StreamFolder.Streams[0]
                        $itemCache[$streamUsages.Path] = $streamUsages
                        $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 ($RecordingTrack -eq 'Secondary' -and $streamUsageChildItem.RecordToValues.Count -eq 0) {
                    Write-Error "Adaptive playback is not available. RecordingTrack parameter must be Primary or None."
                    continue
                }

                if ($PSCmdlet.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
                }

                $recordingTrackId = @{
                    Primary   = '16ce3aa1-5f93-458a-abe5-5c95d9ed1372'
                    Secondary = '84fff8b9-8cd1-46b2-a451-c4a87d4cbbb0'
                    None      = ''
                }
                $compatibilityRecord = if ($RecordingTrack -eq 'Primary') { $true } else { $false }
                if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('RecordingTrack') -and (($streamUsageChildItem.RecordToValues.Count -gt 0 -and $recordingTrackId[$RecordingTrack] -ne $streamUsageChildItem.RecordTo) -or ($streamUsageChildItem.RecordToValues.Count -eq 0 -and $compatibilityRecord -ne $streamUsageChildItem.Record))) {
                    if ($streamUsageChildItem.RecordToValues.Count -gt 0) {
                        # 2023 R2 or later
                        $primaryStreamUsage = $streamUsages.StreamUsageChildItems | Where-Object RecordTo -eq $recordingTrackId.Primary
                        $secondaryStreamUsage = $streamUsages.StreamUsageChildItems | Where-Object RecordTo -eq $recordingTrackId.Secondary
                        switch ($RecordingTrack) {
                            'Primary' {
                                if ($PSCmdlet.ShouldProcess($s.Camera.Name, "Record $($streamUsageChildItem.Name) to the primary recording track")) {
                                    $streamUsageChildItem.RecordTo = $recordingTrackId.Primary

                                    Write-Verbose "Disabling recording on current primary stream '$($primaryStreamUsage.Name)'."
                                    $primaryStreamUsage.RecordTo = $recordingTrackId.None

                                    if ($primaryStreamUsage.LiveMode -eq 'Never') {
                                        Write-Verbose "Changing LiveMode from Never to WhenNeeded on $($primaryStreamUsage.Name)"
                                        $primaryStreamUsage.LiveMode = 'WhenNeeded'
                                    }

                                    if ($streamUsageChildItem.LiveMode -eq 'Never') {
                                        Write-Verbose "Changing LiveMode from Never to WhenNeeded on $($streamUsageChildItem.Name)"
                                        $streamUsageChildItem.LiveMode = 'WhenNeeded'
                                    }

                                    $dirtyStreamUsages = $true
                                }
                            }
                            'Secondary' {
                                if ($PSCmdlet.ShouldProcess($s.Camera.Name, "Record $($streamUsageChildItem.Name) to the secondary recording track")) {
                                    $streamUsageChildItem.RecordTo = $recordingTrackId.Secondary
                                    if ($streamUsageChildItem.LiveMode -eq 'Never') {
                                        Write-Verbose "Changing LiveMode from Never to WhenNeeded on $($streamUsageChildItem.Name)"
                                        $streamUsageChildItem.LiveMode = 'WhenNeeded'
                                    }

                                    if ($secondaryStreamUsage) {
                                        Write-Verbose "Disabling recording on current secondary stream '$($secondaryStreamUsage.Name)'."
                                        $secondaryStreamUsage.RecordTo = $recordingTrackId.None

                                        if ($secondaryStreamUsage.LiveMode -eq 'Never') {
                                            Write-Verbose "Changing LiveMode from Never to WhenNeeded on $($secondaryStreamUsage.Name)"
                                            $secondaryStreamUsage.LiveMode = 'WhenNeeded'
                                        }
                                    }

                                    $dirtyStreamUsages = $true
                                }
                            }
                            'None' {
                                if ($PSCmdlet.ShouldProcess($s.Camera.Name, "Disable recording of stream $($streamUsageChildItem.Name)")) {
                                    $streamUsageChildItem.RecordTo = $recordingTrackId.None
                                    if ($streamUsageChildItem.LiveMode -eq 'Never') {
                                        Write-Verbose "Changing LiveMode from Never to WhenNeeded on $($streamUsageChildItem.Name)"
                                        $streamUsageChildItem.LiveMode = 'WhenNeeded'
                                    }

                                    $streamUsages.StreamUsageChildItems | Where-Object {
                                        $_.StreamReferenceId -ne $streamUsageChildItem.StreamReferenceId -and -not [string]::IsNullOrWhiteSpace($_.RecordTo)
                                    } | Select-Object -First 1 | ForEach-Object {
                                        Write-Verbose "Setting the default playback stream to $($_.Name)"
                                        $_.DefaultPlayback = $true
                                    }

                                    $dirtyStreamUsages = $true
                                }
                            }
                        }
                    } else {
                        # 2023 R1 or earlier
                        $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'
                            }
                        }

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

                if ($PlaybackDefault -and $PlaybackDefault -ne $streamUsageChildItem.DefaultPlayback) {
                    if ($PSCmdlet.ShouldProcess($s.Camera.Name, "Set the default playback stream to $($streamUsageChildItem.Name)")) {
                        $streamUsages.StreamUsageChildItems | ForEach-Object {
                            $_.DefaultPlayback = $false
                        }
                        $streamUsageChildItem.DefaultPlayback = $PlaybackDefault
                        $dirtyStreamUsages = $true
                    }
                }

                if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('UseEdge') -and $UseEdge -ne $streamUsageChildItem.UseEdge) {
                    if ($PSCmdlet.ShouldProcess($s.Camera.Name, "Enable use of edge storage on $($streamUsageChildItem.Name)")) {
                        $streamUsageChildItem.UseEdge = $UseEdge
                        $dirtyStreamUsages = $true
                    }
                }

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

                    if ($PSCmdlet.ShouldProcess($s.Camera.Name, "Enabling LiveDefault on $($streamUsageChildItem.Name)")) {
                        $streamUsageChildItem.LiveDefault = $true
                        $dirtyStreamUsages = $true
                    }
                }

                if ($PSCmdlet.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
                            StreamConfig = $s
                        }
                    )
                }

                $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
                                StreamConfig = $s
                            }
                        )
                    }
                }
            }
        }
    }

    end {
        $updatedStreamConfigs = [system.collections.generic.list[object]]::new()
        foreach ($update in $updatedItems) {
            try {
                $item = $itemCache[$update.Item.Path]
                if ($null -ne $item) {
                    $item.Save()
                }
                if ($update.StreamConfig -notin $updatedStreamConfigs) {
                    $update.StreamConfig.Update()
                    $updatedStreamConfigs.Add($update.StreamConfig)
                }
            } catch [VideoOS.Platform.Proxy.ConfigApi.ValidateResultException] {
                $update.Parent.ClearChildrenCache()
                $_ | HandleValidateResultException -TargetObject $item
            } finally {
                if ($null -ne $item) {
                    $itemCache.Remove($item.Path)
                    $item = $null
                }
            }
        }
    }
}
function Set-VmsConnectionString {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [RequiresVmsConnection($false)]
    param (
        [Parameter(Mandatory, Position = 0)]
        [string]
        $Component,

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

        [Parameter()]
        [switch]
        $Force
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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)]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [ValidateVmsItemType('CameraGroup', 'MicrophoneGroup', 'MetadataGroup', 'SpeakerGroup', 'InputEventGroup', 'OutputGroup')]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $Group,

        [Parameter()]
        [string]
        $Name,

        [Parameter()]
        [string]
        $Description,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $groupType = $Group | Split-VmsConfigItemPath -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-VmsHardware {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.Hardware])]
    [Alias('Set-HardwarePassword')]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Hardware[]]
        $Hardware,

        [Parameter()]
        [bool]
        $Enabled,

        [Parameter()]
        [string]
        $Name,

        [Parameter()]
        [uri]
        $Address,

        [Parameter()]
        [string]
        $UserName,

        [Parameter()]
        [Alias('NewPassword')]
        [ValidateVmsVersion('11.3')]
        [SecureStringTransformAttribute()]
        [ValidateScript({
            if ($_.Length -gt 64) {
                throw "The maximum password length is 64 characters. See Get-Help Set-VmsHardware -Online for more information."
            }
            $true
        })]
        [securestring]
        $Password,

        [Parameter()]
        [ValidateVmsVersion('23.2')]
        [switch]
        $UpdateRemoteHardware,

        [Parameter()]
        [string]
        $Description,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
        if ($UpdateRemoteHardware -and -not $PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Password')) {
            Write-Warning "The UpdateRemoteHardware parameter is ignored because no value was provided for the Password parameter."
        }
    }

    process {
        foreach ($hw in $Hardware) {
            if ($MyInvocation.BoundParameters.ContainsKey('WhatIf') -and $WhatIf -eq $true) {
                # Operate on a separate hardware record to avoid modifying local properties when using WhatIf.
                $hw = Get-VmsHardware -Id $hw.Id
            }
            $initialName = $hw.Name
            $initialAddress = $hw.Address
            $dirty = $false

            foreach ($key in $MyInvocation.BoundParameters.Keys) {
                switch ($key) {
                    'Enabled' {
                        if ($Enabled -ne $hw.Enabled) {
                            Write-Verbose "Changing value of '$key' from $($hw.Enabled) to $Enabled on $initialName."
                            $hw.Enabled = $Enabled
                            $dirty = $true
                        }
                    }

                    'Name' {
                        if ($Name -cne $hw.Name) {
                            Write-Verbose "Changing value of '$key' from $($hw.Name) to $Name."
                            $hw.Name = $Name
                            $dirty = $true
                        }
                    }

                    'Address' {
                        if ($Address -ne [uri]$hw.Address) {
                            Write-Verbose "Changing value of '$key' from $($hw.Address) to $Address on $initialName."
                            $hw.Address = $Address
                            $dirty = $true
                        }
                    }

                    'UserName' {
                        if ($UserName -cne $hw.UserName) {
                            Write-Verbose "Changing value of '$key' from $($hw.UserName) to $UserName on $initialName."
                            $hw.UserName = $UserName
                            $dirty = $true
                        }
                    }

                    'Password' {
                        $action = "Change password in the VMS"
                        if ($UpdateRemoteHardware) {
                            $action += ' and on remote hardware device'
                        }
                        if ($PSCmdlet.ShouldProcess("$initialName", $action)) {
                            try {
                                $invokeResult = $hw.ChangePasswordHardware($Password, $UpdateRemoteHardware.ToBool())
                                if ($invokeResult.Path -match '^Task') {
                                    $invokeResult = $invokeResult | Wait-VmsTask -Title "Updating hardware password for $initialName"
                                }
                                if (($invokeResult.Properties | Where-Object Key -eq 'State').Value -eq 'Error') {
                                    Write-Error -Message "ChangePasswordHardware error: $(($invokeResult.Properties | Where-Object Key -eq 'ErrorText').Value)" -TargetObject $hw
                                }
                            } catch {
                                Write-Error -Message $_.Exception.Message -Exception $_.Exception -TargetObject $hw
                            }
                        }
                    }

                    'Description' {
                        if ($Description -cne $hw.Description) {
                            Write-Verbose "Changing value of '$key' on $initialName."
                            $hw.Description = $Description
                            $dirty = $true
                        }
                    }
                }
            }

            $target = "Hardware '$initialName' ($initialAddress)"
            if ($dirty) {
                if ($PSCmdlet.ShouldProcess($target, "Save changes")) {
                    try {
                        $hw.Save()
                    } catch [VideoOS.Platform.Proxy.ConfigApi.ValidateResultException] {
                        $errorResults = $_.Exception.InnerException.ValidateResult.ErrorResults
                        if ($null -eq $errorResults -or $errorResults.Count -eq 0) {
                            throw
                        }
                        foreach ($result in $errorResults) {
                            Write-Error -Message "Validation error on property '$($result.ErrorProperty)': $($result.ErrorText)"
                        }
                    } catch {
                        Write-Error -ErrorRecord $_ -Exception $_.Exception -TargetObject $hw
                    }
                }
            }

            if ($PassThru) {
                $hw
            }
        }
    }
}
function Set-VmsHardwareDriver {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('23.1')]
    [OutputType([VideoOS.Platform.ConfigurationItems.Hardware])]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Hardware[]]
        $Hardware,

        [Parameter()]
        [uri]
        $Address,

        [Parameter()]
        [pscredential]
        $Credential,

        [Parameter()]
        [HardwareDriverTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.HardwareDriver]
        $Driver,

        [Parameter()]
        [string]
        $CustomDriverData,

        [Parameter()]
        [switch]
        $AllowDeletingDisabledDevices,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
        $tasks = [system.collections.generic.list[VideoOS.ConfigurationApi.ClientService.ConfigurationItem]]::new()
        $taskInfo = @{}
        $recorderPathByHwPath = @{}
    }

    process {
        $hwParams = @{
            AllowDeletingDisabledDevices = $AllowDeletingDisabledDevices.ToString()
        }

        if ($MyInvocation.BoundParameters.ContainsKey('Address')) {
            if ($Address.Scheme -notin 'https', 'http') {
                Write-Error "Address must be in the format http://address or https://address"
                return
            }
            $hwParams.Address   = $Address.Host
            $hwParams.Port      = if ($Address.Scheme -eq 'http') { $Address.Port } else { 80 }
            $hwParams.UseHttps  = if ($Address.Scheme -eq 'https') { 'True' } else { 'False' }
            $hwParams.HttpsPort = if ($Address.Scheme -eq 'https') { $Address.Port } else { 443 }
        }

        if ($MyInvocation.BoundParameters.ContainsKey('Credential')) {
            $hwParams.UserName = $Credential.UserName
            $hwParams.Password = $Credential.GetNetworkCredential().Password
        } else {
            $hwParams.UserName = $Hardware.UserName
            $hwParams.Password = $Hardware | Get-VmsHardwarePassword
        }

        if ($MyInvocation.BoundParameters.ContainsKey('Driver')) {
            $hwParams.Driver = $Driver.Number.ToString()
        }

        if ($MyInvocation.BoundParameters.ContainsKey('CustomDriverData')) {
            $hwParams.CustomDriverData = $CustomDriverData
        }

        foreach ($hw in $Hardware) {
            if ($PSCmdlet.ShouldProcess("$($hw.Name) ($($hw.Address))", "Replace hardware")) {
                $recorderPathByHwPath[$hw.Path] = [videoos.platform.proxy.ConfigApi.ConfigurationItemPath]::new($hw.ParentItemPath)
                $method = 'ReplaceHardware'
                $item = $hw | Get-ConfigurationItem
                if ($method -notin $item.MethodIds) {
                    throw "The $method MethodId is not present. This method was introduced in XProtect VMS version 2023 R1."
                }
                $invokeInfo = $item | Invoke-Method -MethodId $method

                foreach ($key in $hwParams.Keys) {
                    if ($prop = $invokeInfo.Properties | Where-Object Key -eq $key) {
                        $prop.Value = $hwParams[$key]
                    }
                }

                Write-Verbose "ReplaceHardware task properties`r`n$($invokeInfo.Properties | Select-Object Key, @{Name = 'Value'; Expression = {if ($_.Key -eq 'Password') {'*' * 8} else {$_.Value}}} | Out-String)"
                $invokeResult = $invokeInfo | Invoke-Method ReplaceHardware
                $taskPath = ($invokeResult.Properties | Where-Object Key -eq 'Path').Value
                $tasks.Add((Get-ConfigurationItem -Path $taskPath))
                $taskInfo[$taskPath] = @{
                    HardwareName = $hw.Name
                    HardwarePath = [videoos.platform.proxy.ConfigApi.ConfigurationItemPath]::new($hw.Path)
                    RecorderPath = $recorderPathByHwPath[$hw.Path]
                    Task         = $null
                }
            }
        }
    }

    end {
        $recorders = @{}
        $replacedHardwarePaths = [system.collections.generic.list[string]]::new()
        foreach ($task in $tasks) {
            $task = $task | Wait-VmsTask -Cleanup
            if (($task.Properties | Where-Object Key -eq 'State').Value -ne 'Success') {
                $info = $taskInfo[$task.Path]
                $info.Task = $task
                $message = "Unknown error during ReplaceHardware for $($info.HardwareName) ($info.HardwarePath.Id)."
                $taskError = ($task.Properties | Where-Object Key -eq 'ErrorText').Value
                if (-not [string]::IsNullOrWhiteSpace($taskError)) {
                    $message = $taskError
                }
                Write-Error -Message $message -TargetObject ([ReplaceHardwareTaskInfo]$info)
            } else {
                $hwPath = ($task.Properties | Where-Object Key -eq 'HardwareId').Value
                $recPath = $recorderPathByHwPath[$hwPath]
                if (-not $recorders.ContainsKey($recPath.Id)) {
                    $recorders[$recPath.Id] = Get-VmsRecordingServer -Id $recPath.Id
                }
                $replacedHardwarePaths.Add($hwPath)
            }
        }
        foreach ($rec in $recorders.Values) {
            $rec.HardwareFolder.ClearChildrenCache()
        }
        if ($PassThru) {
            foreach ($path in $replacedHardwarePaths) {
                $itemPath = [videoos.platform.proxy.ConfigApi.ConfigurationItemPath]::new($path)
                Get-VmsHardware -HardwareId $itemPath.Id
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Set-VmsHardwareDriver -ParameterName Driver -ScriptBlock {
    $values = Get-RecordingServer | Select-Object -First 1 | Get-VmsHardwareDriver |
        Where-Object Name -like "$wordToComplete*" |
        Sort-Object Name |
        Select-Object -ExpandProperty Name -Unique
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Set-VmsLicense {
    [CmdletBinding()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.2')]
    [OutputType([VideoOS.Platform.ConfigurationItems.LicenseInformation])]
    param (
        [Parameter(Mandatory)]
        [string]
        $Path
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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-VmsLoginProvider {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.LoginProvider])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('22.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [LoginProviderTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.LoginProvider]
        $LoginProvider,

        [Parameter()]
        [string]
        $Name,

        [Parameter()]
        [string]
        $ClientId,

        [Parameter()]
        [SecureStringTransformAttribute()]
        [securestring]
        $ClientSecret,

        [Parameter()]
        [string]
        $CallbackPath,

        [Parameter()]
        [uri]
        $Authority,

        [Parameter()]
        [string]
        $UserNameClaim,

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

        [Parameter()]
        [bool]
        $PromptForLogin,

        [Parameter()]
        [bool]
        $Enabled,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
    }
    
    process {
        try {
            if ($PSCmdlet.ShouldProcess("Login provider '$($LoginProvider.Name)'", "Update")) {
                $dirty = $false
                $initialName = $LoginProvider.Name
                $keys = @()
                $MyInvocation.BoundParameters.GetEnumerator() | Where-Object Key -in $LoginProvider.GetPropertyKeys() | Foreach-Object {
                    $keys += $_.Key
                }
                if ($MyInvocation.BoundParameters.ContainsKey('Enabled')) {
                    $keys += 'Enabled'
                }
                if ($MyInvocation.BoundParameters.ContainsKey('UserNameClaim')) {
                    $keys += 'UserNameClaim'
                }
                foreach ($key in $keys) {
                    if ($key -eq 'Scopes') {
                        $differences = (($Scopes | Foreach-Object { $_ -in $LoginProvider.Scopes}) -eq $false).Count + (($LoginProvider.Scopes | Foreach-Object { $_ -in $Scopes}) -eq $false).Count
                        if ($differences -gt 0) {
                            Write-Verbose "Updating $key on login provider '$initialName'"
                            $LoginProvider.Scopes.Clear()
                            $Scopes | Foreach-Object {
                                $LoginProvider.Scopes.Add($_)
                            }
                            $dirty = $true
                        }
                    } elseif ($key -eq 'ClientSecret') {
                        Write-Verbose "Updating $key on login provider '$initialName'"
                        $cred = [pscredential]::new('a', $ClientSecret)
                        $LoginProvider.ClientSecret = $cred.GetNetworkCredential().Password
                        $dirty = $true
                    } elseif ($key -eq 'Enabled' -and $LoginProvider.Enabled -ne $Enabled) {
                        Write-Verbose "Setting Enabled to $Enabled on login provider '$initialName'"
                        $LoginProvider.Enabled = $Enabled
                        $dirty = $true
                    } elseif ($key -eq 'UserNameClaim') {
                        Write-Verbose "Setting UserNameClaimType to $UserNameClaim on login provider '$initialName'"
                        $LoginProvider.UserNameClaimType = $UserNameClaim
                        $dirty = $true
                    } elseif ($LoginProvider.$key -cne (Get-Variable -Name $key).Value) {
                        Write-Verbose "Updating $key on login provider '$initialName'"
                        $LoginProvider.$key = (Get-Variable -Name $key).Value
                        $dirty = $true
                    }
                }
                if ($dirty) {
                    $LoginProvider.Save()
                } else {
                    Write-Verbose "No changes were made to login provider '$initialName'."
                }
            }

            if ($PassThru) {
                $LoginProvider
            }
        } catch {
            Write-Error -Message $_.Exception.Message -TargetObject $LoginProvider
        }
    }
}

Register-ArgumentCompleter -CommandName Set-VmsLoginProvider -ParameterName LoginProvider -ScriptBlock {
    $values = (Get-VmsLoginProvider).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Set-VmsLoginProviderClaim {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('22.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.RegisteredClaim]
        $Claim,

        [Parameter()]
        [string]
        $Name,

        [Parameter()]
        [string]
        $DisplayName,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $item = $Claim | Get-ConfigurationItem
        $nameProperty = $item.Properties | Where-Object Key -eq 'Name'
        $dirty = $false
        if ($MyInvocation.BoundParameters.ContainsKey('Name') -and $Name -cne $nameProperty.Value) {
            if ($nameProperty.Value -ceq $item.DisplayName) {
                $item.DisplayName = $Name
            }
            $nameProperty.Value = $Name
            $dirty = $true
        }
        if ($MyInvocation.BoundParameters.ContainsKey('DisplayName') -and $DisplayName -cne $item.DisplayName) {
            $item.DisplayName = $DisplayName
            $dirty = $true
        }
        if ($dirty -and $PSCmdlet.ShouldProcess("Registered claim '$($Claim.Name)'", "Update")) {
            $result = $item | Set-ConfigurationItem
        }
        if ($PassThru -and $result.ValidatedOk) {
            $loginProvider = (Get-VmsLoginProvider | Where-Object Path -eq $Claim.ParentItemPath)
            $loginProvider.ClearChildrenCache()
            $loginProvider | Get-VmsLoginProviderClaim -Name $nameProperty.Value
        }
    }
}
function Set-VmsRecordingServer {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    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()]
        [ValidateVmsFeature('RecordingServerFailover')]
        [FailoverGroupNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.FailoverGroup]
        $PrimaryFailoverGroup,

        [Parameter()]
        [ValidateVmsFeature('RecordingServerFailover')]
        [FailoverGroupNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.FailoverGroup]
        $SecondaryFailoverGroup,

        [Parameter()]
        [ValidateVmsFeature('RecordingServerFailover')]
        [FailoverRecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.FailoverRecorder]
        $HotStandbyFailoverRecorder,

        [Parameter()]
        [ValidateVmsFeature('RecordingServerFailover')]
        [switch]
        $DisableFailover,

        [Parameter()]
        [ValidateVmsFeature('RecordingServerFailover')]
        [ValidateRange(0, 65535)]
        [int]
        $FailoverPort,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
        $updateFailoverSettings = $false
        'PrimaryFailoverGroup', 'SecondaryFailoverGroup', 'HotStandbyFailoverRecorder', 'DisableFailover', 'FailoverPort' | Foreach-Object {
            if ($MyInvocation.BoundParameters.ContainsKey($_)) {
                $updateFailoverSettings = $true
            }
        }
    }

    process {
        if ($HotStandbyFailoverRecorder -and ($PrimaryFailoverGroup -or $SecondaryFailoverGroup)) {
            throw "Invalid combination of failover parameters. When specifying a hot standby failover recorder, you may not also assign a primary or secondary failover group."
        }
        if ($PrimaryFailoverGroup -and ($PrimaryFailoverGroup.Path -eq $SecondaryFailoverGroup.Path)) {
            throw "The same failover group cannot be used for both the primary, and secondary failover groups."
        }

        foreach ($rec in $RecordingServer) {
            try {
                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 ($updateFailoverSettings) {

                    $dirtyFailover = $false
                    $failoverSettings = $rec.RecordingServerFailoverFolder.recordingServerFailovers[0]

                    if ($MyInvocation.BoundParameters.ContainsKey('PrimaryFailoverGroup') -and $PrimaryFailoverGroup.Path -ne $failoverSettings.PrimaryFailoverGroup) {
                        $targetName, $targetPath = $PrimaryFailoverGroup.Name, $PrimaryFailoverGroup.Path
                        if ($null -eq $targetName) {
                            $targetName, $targetPath = 'Not used', $failoverSettings.PrimaryFailoverGroupValues['Not used']
                        }

                        if ($PSCmdlet.ShouldProcess($rec.Name, "Set PrimaryFailoverGroup to $targetName")) {
                            $failoverSettings.PrimaryFailoverGroup = $targetPath
                            $failoverSettings.HotStandby = $failoverSettings.HotStandbyValues['Not used']
                            if ($targetPath -eq $failoverSettings.PrimaryFailoverGroupValues['Not used']) {
                                $failoverSettings.SecondaryFailoverGroup = $failoverSettings.SecondaryFailoverGroupValues['Not used']
                            }
                            $dirtyFailover = $true
                        }
                    }

                    if ($MyInvocation.BoundParameters.ContainsKey('SecondaryFailoverGroup') -and $SecondaryFailoverGroup.Path -ne $failoverSettings.SecondaryFailoverGroup) {
                        $targetName, $targetPath = $SecondaryFailoverGroup.Name, $SecondaryFailoverGroup.Path
                        if ($null -eq $targetName) {
                            $targetName, $targetPath = 'Not used', $failoverSettings.SecondaryFailoverGroupValues['Not used']
                        }

                        if ($failoverSettings.PrimaryFailoverGroup -eq 'FailoverGroup[00000000-0000-0000-0000-000000000000]') {
                            Write-Error -Message "You must specify a primary failover group to set the secondary failover group."
                        } elseif ($targetPath -eq $failoverSettings.PrimaryFailoverGroup) {
                            Write-Error -Message "The PrimaryFailoverGroup and SecondaryFailoverGroup must not be the same."
                        } elseif ($PSCmdlet.ShouldProcess($rec.Name, "Set SecondaryFailoverGroup to $targetName")) {
                            $failoverSettings.SecondaryFailoverGroup = $targetPath
                            $failoverSettings.HotStandby = $failoverSettings.HotStandbyValues['Not used']
                            $dirtyFailover = $true
                        }
                    }

                    if ($MyInvocation.BoundParameters.ContainsKey('HotStandbyFailoverRecorder') -and $HotStandbyFailoverRecorder.Path -ne $failoverSettings.HotStandby) {
                        $targetName, $targetPath = $HotStandbyFailoverRecorder.Name, $HotStandbyFailoverRecorder.Path
                        if ($null -eq $targetName) {
                            $targetName, $targetPath = 'Not used', $failoverSettings.HotStandbyValues['Not used']
                        }

                        if ($PSCmdlet.ShouldProcess($rec.Name, "Set hot standby server to $targetName")) {
                            $failoverSettings.PrimaryFailoverGroup = $failoverSettings.PrimaryFailoverGroupValues['Not used']
                            $failoverSettings.SecondaryFailoverGroup = $failoverSettings.SecondaryFailoverGroupValues['Not used']

                            if (-not [string]::IsNullOrWhiteSpace($failoverSettings.HotStandby)) {
                                # Fix for bug #593838. If bug is fixed, consider adding a version check and skip this extra call to Save()
                                $failoverSettings.HotStandby = $failoverSettings.HotStandbyValues['Not used']
                                $failoverSettings.Save()
                            }
                            $failoverSettings.HotStandby = $targetPath
                            $dirtyFailover = $true
                        }
                    }

                    if ($DisableFailover) {
                        if ($PSCmdlet.ShouldProcess($rec.Name, "Disable failover recording")) {
                            $failoverSettings.PrimaryFailoverGroup = $failoverSettings.PrimaryFailoverGroupValues['Not used']
                            $failoverSettings.SecondaryFailoverGroup = $failoverSettings.SecondaryFailoverGroupValues['Not used']
                            $failoverSettings.HotStandby = $failoverSettings.HotStandbyValues['Not used']
                            $dirtyFailover = $true
                        }
                    }

                    if ($MyInvocation.BoundParameters.ContainsKey('FailoverPort') -and $FailoverPort -ne $failoverSettings.FailoverPort) {
                        if ($PSCmdlet.ShouldProcess($rec.Name, "Set failover port to $FailoverPort")) {
                            $failoverSettings.FailoverPort = $FailoverPort
                            $dirtyFailover = $true
                        }
                    }

                    if ($dirtyFailover) {
                        $failoverSettings.Save()
                    }
                }

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

                if ($PassThru) {
                    $rec
                }
            } catch [VideoOS.Platform.Proxy.ConfigApi.ValidateResultException] {
                $rec.RecordingServerFailoverFolder.ClearChildrenCache()
                $_ | HandleValidateResultException -TargetObject $rec -ItemName $rec.Name
            }
        }
    }
}

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

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

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

Register-ArgumentCompleter -CommandName Set-VmsRecordingServer -ParameterName HotStandbyFailoverRecorder -ScriptBlock {
    $values = (Get-VmsFailoverRecorder -Unassigned).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Set-VmsSiteInfo {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.2')]
    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-VmsRequirementsMet
    }

    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-VmsSite).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-VmsSite).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)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    [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-VmsRequirementsMet
    }

    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)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    [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-VmsRequirementsMet
    }

    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)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VmsViewGroupAcl[]]
        $ViewGroupAcl
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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[]])]
    [RequiresVmsConnection($false)]
    param (
        # Specifies a device group path in unix directory form with forward-slashes as separators.
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ValueFromPipeline, Position = 0)]
        [string]
        $Path
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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])]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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-VmsHardwareDriver | 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-VmsHardware | 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()]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [ValidateVmsItemType('Task')]
        [string[]]
        $Path,

        [Parameter()]
        [string]
        $Title,

        [Parameter()]
        [switch]
        $Cleanup
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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-VmsSite | Select-VmsSite
                    }
                }
            }

            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-VmsSite | Select-VmsSite
                        }
                    }
                }
                $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 Copy-VmsClientProfile {
    [CmdletBinding()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('SmartClientProfiles')]
    [OutputType([VideoOS.Platform.ConfigurationItems.ClientProfile])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [ClientProfileTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.ClientProfile]
        $ClientProfile,

        [Parameter(Mandatory, Position = 0)]
        [string]
        $NewName
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $newProfile = New-VmsClientProfile -Name $NewName -Description $ClientProfile.Description -ErrorAction Stop
        if ($ClientProfile.IsDefaultProfile) {
            # New client profiles are by default an exact copy of the default profile. No need to copy attributes to the new profile.
            $newProfile
            return
        }

        foreach ($attributes in $ClientProfile | Get-VmsClientProfileAttributes) {
            $newProfile | Set-VmsClientProfileAttributes -Attributes $attributes -Verbose:($VerbosePreference -eq 'Continue')
        }
    }
}

Register-ArgumentCompleter -CommandName Copy-VmsClientProfile -ParameterName ClientProfile -ScriptBlock {
    $values = (Get-VmsClientProfile).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Export-VmsClientProfile {
    [CmdletBinding()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('SmartClientProfiles')]
    param (
        [Parameter(ValueFromPipeline)]
        [ClientProfileTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.ClientProfile[]]
        $ClientProfile,

        [Parameter(Mandatory, Position = 0)]
        [string]
        $Path,

        [Parameter()]
        [switch]
        $ValueTypeInfo
    )

    begin {
        Assert-VmsRequirementsMet

        $resolvedPath = (Resolve-Path -Path $Path -ErrorAction SilentlyContinue -ErrorVariable rpError).Path
        if ([string]::IsNullOrWhiteSpace($resolvedPath)) {
            $resolvedPath = $rpError.TargetObject
        }
        $Path = $resolvedPath
        $fileInfo = [io.fileinfo]$Path
        if (-not $fileInfo.Directory.Exists) {
            throw ([io.directorynotfoundexception]::new("Directory not found: $($fileInfo.Directory.FullName)"))
        }
        if (($fi = [io.fileinfo]$Path).Extension -ne '.json') {
            Write-Verbose "A .json file extension will be added to the file '$($fi.Name)'"
            $Path += ".json"
        }
        $results = [system.collections.generic.list[pscustomobject]]::new()
    }

    process {
        if ($ClientProfile.Count -eq 0) {
            $ClientProfile = Get-VmsClientProfile
        }
        foreach ($p in $ClientProfile) {
            $results.Add([pscustomobject]@{
                Name        = $p.Name
                Description = $p.Description
                Attributes  = $p | Get-VmsClientProfileAttributes -ValueTypeInfo:$ValueTypeInfo
            })
        }
    }

    end {
        $json = ConvertTo-Json -InputObject $results -Depth 10 -Compress
        [io.file]::WriteAllText($Path, $json, [text.encoding]::UTF8)
    }
}

Register-ArgumentCompleter -CommandName Export-VmsClientProfile -ParameterName ClientProfile -ScriptBlock {
    $values = (Get-VmsClientProfile).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsClientProfile {
    [CmdletBinding(DefaultParameterSetName = 'Name')]
    [OutputType([VideoOS.Platform.ConfigurationItems.ClientProfile])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('SmartClientProfiles')]
    param (
        [Parameter(ParameterSetName = 'Name', ValueFromPipelineByPropertyName, Position = 0)]
        [SupportsWildcards()]
        [string]
        $Name,

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

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

    begin {
        Assert-VmsRequirementsMet
        $folder = (Get-VmsManagementServer -ErrorAction Stop).ClientProfileFolder
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Id' {
                [VideoOS.Platform.ConfigurationItems.ClientProfile]::new($folder.ServerId, "ClientProfile[$Id]")
            }

            'Name' {
                $matchingProfiles = $folder.ClientProfiles | Where-Object {
                    [string]::IsNullOrWhiteSpace($Name) -or $_.Name -like $Name
                }
                if ($matchingProfiles) {
                    $matchingProfiles
                } elseif (-not [system.management.automation.wildcardpattern]::ContainsWildcardCharacters($Name)) {
                    Write-Error -Message "ClientProfile '$Name' not found."
                }
            }

            'DefaultProfile' {
                Get-VmsClientProfile | Where-Object IsDefaultProfile -eq $DefaultProfile
            }

            default {
                throw "ParameterSetName '$_' not implemented."
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsClientProfile -ParameterName Name -ScriptBlock {
    $values = (Get-VmsClientProfile).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsClientProfileAttributes {
    [CmdletBinding()]
    [OutputType([System.Collections.IDictionary])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('SmartClientProfiles')]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [ClientProfileTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.ClientProfile]
        $ClientProfile,

        [Parameter(Position = 0)]
        [string[]]
        $Namespace,

        [Parameter()]
        [switch]
        $ValueTypeInfo
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $namespaces = ($ClientProfile | Get-Member -MemberType Property -Name 'ClientProfile*ChildItems').Name -replace 'ClientProfile(.+)ChildItems', '$1'
        if ($Namespace.Count -eq 0) {
            $Namespace = $namespaces
        }

        foreach ($ns in $Namespace) {
            if ($ns -notin $namespaces) {
                Write-Error "Property 'ClientProfile$($ns)ChildItems' does not exist on client profile '$($ClientProfile.DisplayName)'"
                continue
            }
            $settings = $ClientProfile."ClientProfile$($ns)ChildItems"
            $attributes = [ordered]@{
                Namespace = $ns
            }
            if ($settings.Count -eq 0) {
                Write-Verbose "Ignoring empty client profile namespace '$ns'."
                continue
            }
            foreach ($key in $settings.GetPropertyKeys() | Where-Object { $_ -notmatch '(?<!Locked)Locked$' } | Sort-Object) {
                $attributes[$key] = [pscustomobject]@{
                    Value         = $settings.GetProperty($key)
                    ValueTypeInfo = if ($ValueTypeInfo) { $settings.GetValueTypeInfoList($key) } else { $null }
                    Locked        = $settings."$($key)Locked"
                }
            }
            $attributes
        }
    }
}

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

Register-ArgumentCompleter -CommandName Get-VmsClientProfileAttributes -ParameterName Namespace -ScriptBlock {
    $values = (Get-VmsClientProfile -DefaultProfile | Get-VmsClientProfileAttributes).Namespace | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Import-VmsClientProfile {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.ClientProfile])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('SmartClientProfiles')]
    param (
        [Parameter(Mandatory, Position = 0)]
        [string]
        $Path,

        [Parameter()]
        [switch]
        $Force
    )

    begin {
        Assert-VmsRequirementsMet
        $Path = (Resolve-Path -Path $Path -ErrorAction Stop).Path
        (Get-VmsManagementServer -ErrorAction Stop).ClientProfileFolder.ClearChildrenCache()
        $existingProfiles = @{}
        Get-VmsClientProfile | Foreach-Object {
            $existingProfiles[$_.Name] = $_
        }
        $showVerbose = $VerbosePreference -eq 'Continue'
    }

    process {
        $definitions = [io.file]::ReadAllText($Path, [text.encoding]::UTF8) | ConvertFrom-Json
        foreach ($def in $definitions) {
            try {
                if ($existingProfiles.ContainsKey($def.Name)) {
                    if ($Force) {
                        $current = $existingProfiles[$def.Name]
                        $current | Set-VmsClientProfile -Description $def.Description -ErrorAction Stop -Verbose:$showVerbose
                    } else {
                        Write-Error "ClientProfile '$($def.Name)' already exists. To overwrite existing profiles, try including the -Force switch."
                        continue
                    }
                } else {
                    $current = New-VmsClientProfile -Name $def.Name -Description $def.Description -ErrorAction Stop
                    $existingProfiles[$current.Name] = $current
                }
                foreach ($psObj in $def.Attributes) {
                    $attributes = @{}
                    foreach ($memberName in ($psObj | Get-Member -MemberType NoteProperty).Name) {
                        $attributes[$memberName] = $psObj.$memberName
                    }
                    $current | Set-VmsClientProfileAttributes -Attributes $attributes -Verbose:$showVerbose
                }
                $current
            } catch {
                Write-Error -Message $_.Exception.Message -Exception $_.Exception -TargetObject $def
            }
        }
    }
}
function New-VmsClientProfile {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.ClientProfile])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('SmartClientProfiles')]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 0)]
        [string]
        $Name,

        [Parameter(ValueFromPipelineByPropertyName, Position = 1)]
        [string]
        $Description
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        try {
            $serverTask = (Get-VmsManagementServer -ErrorAction Stop).ClientProfileFolder.AddClientProfile($Name, $Description)
            if ($serverTask.State -ne 'Success') {
                Write-Error -Message "Error creating new client profile: $($serverTask.ErrorText)" -TargetObject $serverTask
                return
            }
            Get-VmsClientProfile -Id ($serverTask.Path -replace 'ClientProfile\[(.+)\]', '$1')
        } catch {
            Write-Error -Message $_.Message -Exception $_.Exception
        }
    }
}
function Remove-VmsClientProfile {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('SmartClientProfiles')]
    param(
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [ClientProfileTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.ClientProfile[]]
        $ClientProfile
    )

    begin {
        Assert-VmsRequirementsMet
        $folder = (Get-VmsManagementServer -ErrorAction Stop).ClientProfileFolder
    }

    process {
        foreach ($p in $ClientProfile) {
            try {
                if ($PSCmdlet.ShouldProcess("ClientProfile $($p.Name)", "Remove")) {
                    $serverTask = $folder.RemoveClientProfile($p.Path)
                    if ($serverTask.State -ne 'Success') {
                        Write-Error -Message "Error creating new client profile: $($serverTask.ErrorText)" -TargetObject $serverTask
                        return
                    }
                }
            } catch {
                Write-Error -Message $_.Message -Exception $_.Exception -TargetObject $p
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Remove-VmsClientProfile -ParameterName ClientProfile -ScriptBlock {
    $values = (Get-VmsClientProfile).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Set-VmsClientProfile {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.ClientProfile])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('SmartClientProfiles')]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [ClientProfileTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.ClientProfile]
        $ClientProfile,

        [Parameter()]
        [string]
        $Name,

        [Parameter()]
        [string]
        $Description,

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

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
        if ($MyInvocation.BoundParameters.ContainsKey('Priority')) {
            (Get-VmsManagementServer -ErrorAction Stop).ClientProfileFolder.ClearChildrenCache()
            $clientProfiles = Get-VmsClientProfile
        }
    }

    process {
        try {
            $dirty = $false
            if (-not [string]::IsNullOrWhiteSpace($Name) -and $Name -cne $ClientProfile.Name) {
                $dirty = $true
            } else {
                $Name = $ClientProfile.Name
            }
            if ($MyInvocation.BoundParameters.ContainsKey('Description') -and $Description -cne $ClientProfile.Description) {
                $dirty = $true
            } else {
                $Description = $ClientProfile.Description
            }

            $priorityDifference = 0
            if ($MyInvocation.BoundParameters.ContainsKey('Priority')) {
                $currentPriority = 1..($clientProfiles.Count) | Where-Object { $ClientProfile.Path -eq $clientProfiles[$_ - 1].Path }
                $priorityDifference = $Priority - $currentPriority
                if ($priorityDifference) {
                    $dirty = $true
                }
            }

            if ($dirty -and $PSCmdlet.ShouldProcess("ClientProfile '$($ClientProfile.Name)'", "Update")) {
                if ($MyInvocation.BoundParameters.ContainsKey('Name') -or $MyInvocation.BoundParameters.ContainsKey('Description')) {
                    $ClientProfile.Name = $Name
                    $ClientProfile.Description = $Description
                    $ClientProfile.Save()
                }

                if ($priorityDifference -lt 0) {
                    do {
                        $null = $ClientProfile.ClientProfileUpPriority()
                    } while ((++$priorityDifference))
                } elseif ($priorityDifference -gt 0) {
                    $priorityDifference = [math]::Min($priorityDifference, $clientProfiles.Count)
                    do {
                        $null = $ClientProfile.ClientProfileDownPriority()
                    } while ((--$priorityDifference))
                }
            }

            if ($PassThru) {
                $ClientProfile
            }
        } catch {
            Write-Error -Message $_.Exception.Message -Exception $_.Exception -TargetObject $ClientProfile
        }
    }

    end {
        (Get-VmsManagementServer).ClientProfileFolder.ClearChildrenCache()
    }
}

Register-ArgumentCompleter -CommandName Set-VmsClientProfile -ParameterName ClientProfile -ScriptBlock {
    $values = (Get-VmsClientProfile).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Set-VmsClientProfileAttributes {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('SmartClientProfiles')]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [ClientProfileTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.ClientProfile]
        $ClientProfile,

        [Parameter(Position = 0)]
        [System.Collections.IDictionary]
        $Attributes,

        [Parameter()]
        [string]
        $Namespace
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $namespaces = ($ClientProfile | Get-Member -MemberType Property -Name 'ClientProfile*ChildItems').Name -replace 'ClientProfile(.+)ChildItems', '$1'
        if (-not $MyInvocation.BoundParameters.ContainsKey('Namespace')) {
            $Namespace = $Attributes.Namespace
        }
        if ([string]::IsNullOrWhiteSpace($Namespace)) {
            Write-Error "Client profile attribute namespace required. Either supply the namespace using the Namespace parameter, or include a Namespace key in the Attributes dictionary with the appropriate namespace name as a string value."
            return
        } elseif ($Namespace -notin $namespaces) {
            Write-Error "Client profile namespace '$Namespace' not found. Namespaces include $($namespaces -join ', ')."
            return
        }

        $settings = $ClientProfile."ClientProfile$($Namespace)ChildItems"
        $availableKeys = $settings.GetPropertyKeys()
        $dirty = $false
        foreach ($key in $Attributes.Keys | Where-Object { $_ -ne 'Namespace'}) {
            if ($key -notin $availableKeys) {
                Write-Warning "Client profile attribute with key '$key' not found in client profile namespace '$Namespace'."
                continue
            }

            if ($Attributes[$key].Value) {
                $newValue = $Attributes[$key].Value.ToString()
            } else {
                $newValue = $Attributes[$key].ToString()
            }

            if ($settings.GetProperty($key) -cne $newValue -and $PSCmdlet.ShouldProcess("$($ClientProfile.Name)/$Namespace/$key", "Change value from '$($settings.GetProperty($key))' to '$newValue'")) {
                $settings.SetProperty($key, $newValue)
                $dirty = $true
            }

            $locked = $null
            if ("$($key)Locked" -in $availableKeys) {
                $locked = $settings.GetProperty("$($key)Locked")
            }
            if ($null -ne $locked -and $null -ne $Attributes[$key].Locked -and $locked -ne $Attributes[$key].Locked.ToString() -and $PSCmdlet.ShouldProcess("$($ClientProfile.Name)/$Namespace/$($key)Locked", "Change value from '$locked' to '$($Attributes[$key].Locked.ToString())'")) {
                $settings.SetProperty("$($key)Locked", $Attributes[$key].Locked.ToString())
                $dirty = $true
            }
        }
        if ($dirty) {
            $ClientProfile.Save()
        }
    }
}

Register-ArgumentCompleter -CommandName Set-VmsClientProfileAttributes -ParameterName ClientProfile -ScriptBlock {
    $values = (Get-VmsClientProfile).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Connect-Vms {
    [CmdletBinding(DefaultParameterSetName = 'ConnectionProfile')]
    [OutputType([VideoOS.Platform.ConfigurationItems.ManagementServer])]
    [RequiresVmsConnection($false)]
    param (
        [Parameter(ParameterSetName = 'ConnectionProfile', ValueFromPipelineByPropertyName, Position = 0)]
        [Parameter(ParameterSetName = 'ServerAddress')]
        [Parameter(ParameterSetName = 'ShowDialog')]
        [string]
        $Name = 'default',

        [Parameter(ParameterSetName = 'ShowDialog', ValueFromPipelineByPropertyName)]
        [RequiresInteractiveSession()]
        [switch]
        $ShowDialog,

        [Parameter(ParameterSetName = 'ServerAddress', Mandatory, ValueFromPipelineByPropertyName)]
        [uri]
        $ServerAddress,

        [Parameter(ParameterSetName = 'ServerAddress', ValueFromPipelineByPropertyName)]
        [pscredential]
        $Credential,

        [Parameter(ParameterSetName = 'ServerAddress', ValueFromPipelineByPropertyName)]
        [switch]
        $BasicUser,

        [Parameter(ParameterSetName = 'ServerAddress', ValueFromPipelineByPropertyName)]
        [switch]
        $SecureOnly,

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

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

        [Parameter(ParameterSetName = 'ConnectionProfile')]
        [switch]
        $NoProfile
    )

    begin {
        Assert-VmsRequirementsMet
    }
        
    process {
        Disconnect-Vms
        
        switch ($PSCmdlet.ParameterSetName) {
            'ConnectionProfile' {
                $vmsProfile = GetVmsConnectionProfile -Name $Name
                if ($vmsProfile) {
                    if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('IncludeChildSites')) {
                        $vmsProfile['IncludeChildSites'] = $IncludeChildSites
                    }
                    Connect-ManagementServer @vmsProfile -Force -ErrorAction Stop
                } else {
                    Connect-ManagementServer -ShowDialog -AcceptEula:$AcceptEula -IncludeChildSites:$IncludeChildSites -Force -ErrorAction Stop
                }
            }

            'ServerAddress' {
                $connectArgs = @{
                    ServerAddress     = $ServerAddress
                    SecureOnly        = $SecureOnly
                    IncludeChildSites = $IncludeChildSites
                    AcceptEula        = $AcceptEula
                }
                if ($Credential) {
                    $connectArgs.Credential = $Credential
                    $connectArgs.BasicUser = $BasicUser
                }
                Connect-ManagementServer @connectArgs -ErrorAction Stop
            }

            'ShowDialog' {
                if ($ShowDialog) {
                    $connectArgs = @{
                        ShowDialog        = $ShowDialog
                        IncludeChildSites = $IncludeChildSites
                        AcceptEula        = $AcceptEula
                    }
                    Connect-ManagementServer @connectArgs -ErrorAction Stop
                }
            }

            Default {
                throw "ParameterSetName '$_' not implemented."
            }
        }

        if (Test-VmsConnection) {
            if (-not $NoProfile -and ($PSCmdlet.ParameterSetName -eq 'ConnectionProfile' -or $PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Name'))) {
                Save-VmsConnectionProfile -Name $Name -Force
            }
            
            Get-VmsManagementServer
        }
    }
}

Register-ArgumentCompleter -CommandName Connect-Vms, Get-VmsConnectionProfile, Save-VmsConnectionProfile, Remove-VmsConnectionProfile -ParameterName Name -ScriptBlock {
    $options = (GetVmsConnectionProfile -All).Keys | Sort-Object
    if ([string]::IsNullOrWhiteSpace($args[2])) {
        $wordToComplete = '*'
    } else {
        $wordToComplete = $args[2].Trim('''').Trim('"')
    }

    $options | ForEach-Object {
        if ($_ -like "$wordToComplete*") {
            if ($_ -match '\s') {
                "'$_'"
            } else {
                $_
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Connect-Vms -ParameterName ServerAddress -ScriptBlock {
    $options = (GetVmsConnectionProfile -All).Values | ForEach-Object { $_.ServerAddress.ToString() } | Sort-Object
    if ([string]::IsNullOrWhiteSpace($args[2])) {
        $wordToComplete = '*'
    } else {
        $wordToComplete = $args[2].Trim('''').Trim('"')
    }

    $options | ForEach-Object {
        if ($_ -like "$wordToComplete*") {
            if ($_ -match '\s') {
                "'$_'"
            } else {
                $_
            }
        }
    }
}
function Disconnect-Vms {
    [CmdletBinding()]
    [RequiresVmsConnection($false)]
    param ()

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        if ([milestonepstools.connection.milestoneconnection]::Instance) {
            Disconnect-ManagementServer
        }
    }
}
function Get-VmsConnectionProfile {
    [CmdletBinding(DefaultParameterSetName = 'Name')]
    [Alias('Get-Vms')]
    [OutputType([pscustomobject])]
    [RequiresVmsConnection($false)]
    param(
        [Parameter(ParameterSetName = 'Name', ValueFromPipelineByPropertyName, Position = 0)]
        [string]
        $Name = 'default',

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

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $vmsProfiles = GetVmsConnectionProfile -All
        foreach ($profileName in $vmsProfiles.Keys | Sort-Object) {
            if ($All -or $profileName -eq $Name) {
                [pscustomobject]@{
                    Name              = $profileName
                    ServerAddress     = $vmsProfiles[$profileName].ServerAddress
                    Credential        = $vmsProfiles[$profileName].Credential
                    BasicUser         = $vmsProfiles[$profileName].BasicUser
                    SecureOnly        = $vmsProfiles[$profileName].SecureOnly
                    IncludeChildSites = $vmsProfiles[$profileName].SecureOnly
                    AcceptEula        = $vmsProfiles[$profileName].AcceptEula
                }
            }
        }
    }
}
function Remove-VmsConnectionProfile {
    [CmdletBinding()]
    [RequiresVmsConnection($false)]
    param (
        [Parameter(ParameterSetName = 'Name', Mandatory, ValueFromPipelineByPropertyName, Position = 0)]
        [string[]]
        $Name,

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

    begin {
        Assert-VmsRequirementsMet
    }
    
    process {
        $vmsProfiles = GetVmsConnectionProfile -All
        if ($All) {
            $vmsProfiles.Clear()
        } else {
            $Name | ForEach-Object {
                $vmsProfiles.Remove($_)
            }
        }

        $vmsProfiles | Export-Clixml -Path (GetVmsConnectionProfilePath) -Force
    }
}
function Save-VmsConnectionProfile {
    [CmdletBinding()]
    [RequiresVmsConnection()]
    param(
        [Parameter(Position = 0)]
        [string]
        $Name = 'default',

        [Parameter()]
        [switch]
        $Force
    )

    begin {
        Assert-VmsRequirementsMet
    }
    
    process {
        $vmsProfiles = GetVmsConnectionProfile -All
        if ($vmsProfiles.ContainsKey($Name) -and -not $Force) {
            Write-Error "Connection profile '$Name' already exists. To overwrite it, use the -Force parameter."
            return
        }
        
        $vmsProfiles[$Name] = ExportVmsLoginSettings -ErrorAction Stop
        $vmsProfiles | Export-Clixml -Path (GetVmsConnectionProfilePath) -Force
    }
}
function Test-VmsConnection {
    [CmdletBinding()]
    [RequiresVmsConnection($false)]
    param ()

    begin {
        Assert-VmsRequirementsMet
    }
    
    process {
        $null -ne [MilestonePSTools.Connection.MilestoneConnection]::Instance
    }
}
function Get-VmsDeviceEvent {
    [CmdletBinding()]
    [MilestonePSTools.RequiresVmsConnection()]
    [MilestonePSTools.RequiresVmsVersion('21.1')]
    [OutputType([VideoOS.Platform.ConfigurationItems.HardwareDeviceEventChildItem])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [ValidateVmsItemType('Hardware', 'Camera', 'Microphone', 'Speaker', 'Metadata', 'InputEvent', 'Output')]
        [VideoOS.Platform.ConfigurationItems.IConfigurationItem]
        $Device,

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

        [Parameter()]
        [bool]
        $Used,

        [Parameter()]
        [bool]
        $Enabled
    )

    begin {
        Assert-VmsRequirementsMet
    }
   
    process {
        $Device.HardwareDeviceEventFolder.ClearChildrenCache()
        $hardwareDeviceEvent = $Device.HardwareDeviceEventFolder.HardwareDeviceEvents | Select-Object -First 1
        $wildcardPattern = [system.management.automation.wildcardpattern]::new($Name, [System.Management.Automation.WildcardOptions]::IgnoreCase)
        foreach ($childItem in $hardwareDeviceEvent.HardwareDeviceEventChildItems | Sort-Object DisplayName) {
            if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Name')) {
                if (-not $wildcardPattern.IsMatch($childItem.DisplayName)) {
                    continue
                }
            }
            if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Used') -and $childItem.EventUsed -ne $Used) {
                continue
            }
            if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Enabled') -and $childItem.Enabled -ne $Enabled) {
                continue
            }
            
            # Used in Set-VmsDeviceEvent for more useful log messages and so that it's easy to know which event is associated with which device
            $childItem | Add-Member -MemberType NoteProperty -Name Device -Value $Device
            # Used in Set-VmsDeviceEvent because the .Save() method is on the parent HardwareDeviceEvent, not the HardwareDeviceEventChildItem.
            $childItem | Add-Member -MemberType NoteProperty -Name HardwareDeviceEvent -Value $hardwareDeviceEvent
            # Used in Set-VmsDeviceEvent to know whether to refresh our HardwareDeviceEvent before calling .Save().
            $hwPath = if ($Device.ParentItemPath -match '^Hardware') { $Device.ParentItemPath } else { $Device.Path }
            $childItem | Add-Member -MemberType NoteProperty -Name HardwarePath -Value $hwPath
            
            $childItem
        }
    }
}
function Set-VmsDeviceEvent {
    [CmdletBinding(SupportsShouldProcess)]
    [MilestonePSTools.RequiresVmsConnection()]
    [MilestonePSTools.RequiresVmsVersion('21.1')]
    [OutputType('None')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [ValidateScript({
                if ($null -eq ($_ | Get-Member -MemberType NoteProperty -Name HardwareDeviceEvent)) {
                    throw 'DeviceEvent must be returned by Get-VmsDeviceEvent or it does not have a NoteProperty member named HardwareDeviceEvent.'
                }
                $true
            })]
        [VideoOS.Platform.ConfigurationItems.HardwareDeviceEventChildItem]
        $DeviceEvent,

        [Parameter()]
        [bool]
        $Used,

        [Parameter()]
        [bool]
        $Enabled,

        [Parameter()]
        [string]
        $Index,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
        $modified = @{}
    }
   
    process {
        $changes = @{}
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Used') -and $DeviceEvent.EventUsed -ne $Used) {
            $changes['EventUsed'] = $Used            
        }
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Enabled') -and $DeviceEvent.Enabled -ne $Enabled) {
            $changes['Enabled'] = $Enabled         
        }
        if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Index') -and $DeviceEvent.EventIndex -ne $Index) {
            $changes['EventIndex'] = $Index
        }

        # Management Client sets EventUsed and Enabled to the same value when you add or remove them in the UI.
        if ($changes.ContainsKey('EventUsed') -and $changes['EventUsed'] -ne $DeviceEvent.Enabled) {
            $changes['Enabled'] = $changes['EventUsed']
        }

        if ($changes.Count -gt 0 -and $PSCmdlet.ShouldProcess($DeviceEvent.Device.Name, "Update '$($DeviceEvent.DisplayName)' device event settings")) {
            <#
             # BUG #627670 - This method does not work because you can only call Save() on the most recently queried HardwareDeviceEvent.
             # The LastModified datetime for the Hardware associated with the most recently queried HardwareDeviceEvent must match the
             # LastModified timestamp of the hardware associated with the HardwareDeviceEvent.Save() method.
             # This method will be ~30% faster if we can change the server-side behavior.
             
            foreach ($kvp in $changes.GetEnumerator()) {
                $DeviceEvent.($kvp.Key) = $kvp.Value
            }
            $modified[$DeviceEvent.Path] = $DeviceEvent
             
            #>

            
            
            # Alternate method to work around issue described in BUG #627670
            if (-not $modified.ContainsKey($DeviceEvent.Path)) {
                $modified[$DeviceEvent.Path] = [pscustomobject]@{
                    Device              = $DeviceEvent.Device
                    HardwareDeviceEvent = $DeviceEvent.HardwareDeviceEvent
                    Changes             = @{}
                }
            }
            $modified[$DeviceEvent.Path].Changes[$DeviceEvent.Id] = $changes
        } elseif ($PassThru) {
            $DeviceEvent
        }
    }

    end {
        <#
             # BUG #627670 - This method does not work because you can only call Save() on the most recently queried HardwareDeviceEvent.
             # The LastModified datetime for the Hardware associated with the most recently queried HardwareDeviceEvent must match the
             # LastModified timestamp of the hardware associated with the HardwareDeviceEvent.Save() method.
             # This method will be ~30% faster if we can change the server-side behavior.
 
             foreach ($item in $modified.Values) {
                try {
                    Write-Verbose "Saving device event changes on $($item.Device.Name)."
                    $item.HardwareDeviceEvent.Save()
                    if ($PassThru) {
                        $item
                    }
                } catch {
                    throw
                }
            }
        #>


        # Alternate method to work around issue described in BUG #627670
        foreach ($record in $modified.Values) {
            $record.Device.HardwareDeviceEventFolder.ClearChildrenCache()
            $hardwareDeviceEvent = [VideoOS.Platform.ConfigurationItems.HardwareDeviceEvent]::new($record.HardwareDeviceEvent.ServerId, $record.HardwareDeviceEvent.Path)
            $modifiedChildItems = $hardwareDeviceEvent.HardwareDeviceEventChildItems | Where-Object { $record.Changes.ContainsKey($_.Id) }
            foreach ($eventId in $record.Changes.Keys) {
                if (($childItem = $modifiedChildItems | Where-Object Id -eq $eventId)) {
                    foreach ($change in $record.Changes[$eventId].GetEnumerator()) {
                        Write-Verbose "Setting $($change.Key) = $($change.Value) for event '$($childItem.DisplayName)' on $($record.Device.Name)."
                        $childItem.($change.Key) = $change.Value
                    }
                } else {
                    throw "HardwareDeviceEventChildItem with ID $eventId not found on $($record.Device.Name)."
                }
            }
            Write-Verbose "Saving changes to HardwareDeviceEvents on $($record.Device.Name)"
            $hardwareDeviceEvent.Save()
            if ($PassThru) {
                $record.Device.HardwareDeviceEventFolder.ClearChildrenCache()
                $record.Device | Get-VmsDeviceEvent | Where-Object Id -in $modifiedChildItems.Id
            }
        }
    }
}
function Add-VmsFailoverRecorder {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('RecordingServerFailover')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [FailoverGroupNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.FailoverGroup]
        $FailoverGroup,

        [Parameter(Mandatory, Position = 0)]
        [FailoverRecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.FailoverRecorder[]]
        $FailoverRecorder,

        [Parameter()]
        [int]
        $Position = 0
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        foreach ($failover in $FailoverRecorder) {
            if ($PSCmdlet.ShouldProcess("FailoverGroup $($FailoverGroup.Name)", "Add $($failover.Name)")) {
                try {
                    $serverTask = (Get-VmsManagementServer).FailoverGroupFolder.MoveFailoverGroup($failover.Path, $FailoverGroup.Path, $Position)
                    while ($serverTask.Progress -lt 100) {
                        Start-Sleep -Milliseconds 100
                        $serverTask.UpdateState()
                    }
                    if ($serverTask.State -ne 'Success') {
                        Write-Error -Message "MoveFailoverGroup returned with ErrorCode $($serverTask.ErrorCode). $($serverTask.ErrorText)" -TargetObject $serverTask
                        return
                    }
                } catch {
                    throw
                }
            }
        }
    }
}

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

Register-ArgumentCompleter -CommandName Add-VmsFailoverRecorder -ParameterName FailoverRecorder -ScriptBlock {
    $values = (Get-VmsFailoverRecorder -Unassigned).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsFailoverGroup {
    [CmdletBinding(DefaultParameterSetName = 'Name')]
    [OutputType([VideoOS.Platform.ConfigurationItems.FailoverGroup])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('RecordingServerFailover')]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Id')]
        [guid]
        $Id,

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

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'Id' {
                try {
                    $serverId = (Get-VmsManagementServer).ServerId
                    $path = 'FailoverGroup[{0}]' -f $Id
                    [VideoOS.Platform.ConfigurationItems.FailoverGroup]::new($serverId, $path)
                } catch {
                    throw
                }
            }
            'Name' {
                foreach ($group in (Get-VmsManagementServer).FailoverGroupFolder.FailoverGroups | Where-Object Name -like $Name) {
                    $group
                }
            }
            Default {
                throw "ParameterSetName '$_' not implemented."
            }
        }
    }
}


Register-ArgumentCompleter -CommandName Get-VmsFailoverGroup -ParameterName Name -ScriptBlock {
    $values = (Get-VmsFailoverGroup).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsFailoverRecorder {
    [CmdletBinding(DefaultParameterSetName = 'FailoverGroup')]
    [OutputType([VideoOS.Platform.ConfigurationItems.FailoverRecorder])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('RecordingServerFailover')]
    param (
        [Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName, ParameterSetName = 'FailoverGroup')]
        [FailoverGroupNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.FailoverGroup]
        $FailoverGroup,

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

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

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

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

    begin {
        Assert-VmsRequirementsMet
        if ($HotStandby -or $Unassigned) {
            $failovers = (Get-VmsManagementServer).FailoverGroupFolder.FailoverRecorders
            $hotFailovers = Get-VmsRecordingServer | Foreach-Object {
                $_.RecordingServerFailoverFolder.RecordingServerFailovers[0].HotStandby
            } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
        }
    }

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'FailoverGroup' {
                if ($FailoverGroup) {
                    $FailoverGroup.FailoverRecorderFolder.FailoverRecorders
                } else {
                    (Get-VmsManagementServer).FailoverGroupFolder.FailoverRecorders
                    if ($Recurse) {
                        Get-VmsFailoverGroup | Get-VmsFailoverRecorder
                    }
                }
            }
            'HotStandby' {
                if ($failovers.Count -eq 0) {
                    return
                }
                $failovers | Where-Object Path -in $hotFailovers
            }
            'Unassigned' {
                if ($failovers.Count -eq 0) {
                    return
                }
                $failovers | Where-Object Path -notin $hotFailovers
            }
            'Id' {
                try {
                    $serverId = (Get-VmsManagementServer).ServerId
                    $path = 'FailoverRecorder[{0}]' -f $Id
                    [VideoOS.Platform.ConfigurationItems.FailoverRecorder]::new($serverId, $path)
                } catch {
                    throw
                }
            }
            Default {
                throw "ParameterSetName '$_' not implemented."
            }
        }
    }
}
function New-VmsFailoverGroup {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.FailoverGroup])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('RecordingServerFailover')]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 0)]
        [string]
        $Name,

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

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        if (-not $PSCmdlet.ShouldProcess("FailoverGroup $Name", "Create")) {
            return
        }
        try {
            $serverTask = (Get-VmsManagementServer).FailoverGroupFolder.AddFailoverGroup($Name, $Description)
            while ($serverTask.Progress -lt 100) {
                Start-Sleep -Milliseconds 100
                $serverTask.UpdateState()
            }
            if ($serverTask.State -ne 'Success') {
                Write-Error -Message "AddFailoverGroup returned with ErrorCode $($serverTask.ErrorCode). $($serverTask.ErrorText)" -TargetObject $serverTask
                return
            }
            $id = $serverTask.Path -replace 'FailoverGroup\[(.+)\]', '$1'
            Get-VmsFailoverGroup -Id $id
        } catch {
            throw
        }
    }
}
function Remove-VmsFailoverGroup {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "High")]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('RecordingServerFailover')]
    param(
        [Parameter(Mandatory, ValueFromPipeline, Position = 0)]
        [FailoverGroupNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.FailoverGroup]
        $FailoverGroup,

        [Parameter()]
        [switch]
        $Force
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        if ($PSCmdlet.ShouldProcess($FailoverGroup.Name, "Remove FailoverGroup")) {
            if ($FailoverGroup.FailoverRecorderFolder.FailoverRecorders.Count -gt 0) {
                if (-not $Force) {
                    throw "Cannot delete FailoverGroup with members. Try again with -Force switch to remove member FailoverRecorders."
                }
                $FailoverGroup | Get-VmsFailoverRecorder | Foreach-Object {
                    $FailoverGroup | Remove-VmsFailoverRecorder -FailoverRecorder $_ -Confirm:$false
                }
            }
            try {
                $serverTask = (Get-VmsManagementServer).FailoverGroupFolder.RemoveFailoverGroup($FailoverGroup.Path)
                while ($serverTask.Progress -lt 100) {
                    Start-Sleep -Milliseconds 100
                    $serverTask.UpdateState()
                }
                if ($serverTask.State -ne 'Success') {
                    Write-Error -Message "RemoveFailoverGroup returned with ErrorCode $($serverTask.ErrorCode). $($serverTask.ErrorText)" -TargetObject $serverTask
                }
            } catch {
                throw
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Remove-VmsFailoverGroup -ParameterName FailoverGroup -ScriptBlock {
    $values = (Get-VmsFailoverGroup).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Remove-VmsFailoverRecorder {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('RecordingServerFailover')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [FailoverGroupNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.FailoverGroup]
        $FailoverGroup,

        [Parameter(Mandatory, Position = 0)]
        [FailoverRecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.FailoverRecorder]
        $FailoverRecorder
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        if (-not $PSCmdlet.ShouldProcess("FailoverGroup $($FailoverGroup.Name)", "Remove $($FailoverRecorder)")) {
            return
        }

        try {
            $serverTask = (Get-VmsManagementServer).FailoverGroupFolder.MoveFailoverGroup($FailoverRecorder.Path, [string]::Empty, 0)
            while ($serverTask.Progress -lt 100) {
                Start-Sleep -Milliseconds 100
                $serverTask.UpdateState()
            }
            if ($serverTask.State -ne 'Success') {
                Write-Error -Message "MoveFailoverGroup returned with ErrorCode $($serverTask.ErrorCode). $($serverTask.ErrorText)" -TargetObject $serverTask
                return
            }
        } catch {
            throw
        } finally {
            $FailoverGroup.ClearChildrenCache()
        }
    }
}

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

Register-ArgumentCompleter -CommandName Remove-VmsFailoverRecorder -ParameterName FailoverRecorder -ScriptBlock {
    $values = (Get-VmsFailoverGroup | Get-VmsFailoverRecorder).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Set-VmsFailoverGroup {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.FailoverGroup])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('RecordingServerFailover')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [FailoverGroupNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.FailoverGroup]
        $FailoverGroup,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 0)]
        [string]
        $Name,

        [Parameter()]
        [string]
        $Description,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $dirty = $false
        if (-not [string]::IsNullOrWhiteSpace($Name) -and $Name -cne $FailoverGroup.Name -and $PSCmdlet.ShouldProcess($FailoverGroup.Name, "Rename to $Name")) {
            $FailoverGroup.Name = $Name
            $dirty = $true
        }
        if ($MyInvocation.BoundParameters.ContainsKey('Description') -and $Description -cne $FailoverGroup.Description -and $PSCmdlet.ShouldProcess($FailoverGroup.Name, "Set Description to $Description")) {
            $FailoverGroup.Description = $Description
            $dirty = $true
        }
        if ($dirty) {
            try {
                $FailoverGroup.Save()
            } catch {
                throw
            }
        }
        if ($PassThru) {
            $FailoverGroup
        }
    }
}

Register-ArgumentCompleter -CommandName Set-VmsFailoverGroup -ParameterName FailoverGroup -ScriptBlock {
    $values = (Get-VmsFailoverGroup).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Set-VmsFailoverRecorder {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.FailoverRecorder])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('21.2')]
    [RequiresVmsFeature('RecordingServerFailover')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [FailoverRecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.FailoverRecorder]
        $FailoverRecorder,

        [Parameter()]
        [string]
        $Name,

        [Parameter()]
        [bool]
        $Enabled,

        [Parameter()]
        [string]
        $Description,

        [Parameter()]
        [string]
        $DatabasePath,

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

        [Parameter()]
        [string]
        $MulticastServerAddress,

        [Parameter()]
        [bool]
        $PublicAccessEnabled,

        [Parameter()]
        [string]
        $PublicWebserverHostName,

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

        [Parameter()]
        [switch]
        $Unassigned,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        if ($Unassigned) {
            if ($FailoverRecorder.ParentItemPath -eq '/') {
                Get-VmsRecordingServer | Where-Object {
                    $_.RecordingServerFailoverFolder.RecordingServerFailovers[0].HotStandby -eq $FailoverRecorder.Path
                } | Set-VmsRecordingServer -DisableFailover -Verbose:($VerbosePreference -eq 'Continue')
            } else {
                $group = Get-VmsFailoverGroup -Id ($FailoverRecorder.ParentItemPath -replace '\w+\[(.+)\]', '$1')
                $group | Remove-VmsFailoverRecorder -FailoverRecorder $FailoverRecorder
            }
        }

        $dirty = $false
        $settableProperties = ($FailoverRecorder | Get-Member -MemberType Property | Where-Object Definition -match 'set;').Name
        foreach ($property in $MyInvocation.BoundParameters.GetEnumerator() | Where-Object Key -in $settableProperties) {
            $key = $property.Key
            $newValue = $property.Value
            if ($FailoverRecorder.$key -cne $newValue -and $PSCmdlet.ShouldProcess("FailoverRecorder $($FailoverRecorder.Name)", "Change $key to $newValue")) {
                $FailoverRecorder.$key = $newValue
                $dirty = $true
            }
        }
        if ($dirty) {
            try {
                if ($FailoverRecorder.MulticastServerAddress -eq [string]::Empty) {
                    Write-Verbose 'Changing MulticastServerAddress to 0.0.0.0 because an empty string will not pass validation as of XProtect 2023 R1. Bug #581349.'
                    $FailoverRecorder.MulticastServerAddress = '0.0.0.0'
                }
                $FailoverRecorder.Save()
            } catch [VideoOS.Platform.Proxy.ConfigApi.ValidateResultException] {
                $FailoverRecorder = Get-VmsFailoverRecorder -Id $FailoverRecorder.Id
                $_ | HandleValidateResultException -TargetObject $FailoverRecorder -ItemName $FailoverRecorder.Name
            }
        }
        if ($PassThru) {
            $FailoverRecorder
        }
    }
}

Register-ArgumentCompleter -CommandName Set-VmsFailoverRecorder -ParameterName FailoverRecorder -ScriptBlock {
    $values = (Get-VmsFailoverRecorder -Recurse).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Move-VmsHardware {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([VideoOS.Platform.ConfigurationItems.Hardware])]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [VideoOS.Platform.ConfigurationItems.Hardware[]]
        $Hardware,

        [Parameter(Mandatory, Position = 1, ValueFromPipelineByPropertyName)]
        [RecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.RecordingServer]
        $DestinationRecorder,

        [Parameter(Mandatory, Position = 2, ValueFromPipelineByPropertyName)]
        [StorageNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Storage]
        $DestinationStorage,

        [Parameter()]
        [switch]
        $AllowDataLoss,

        [Parameter()]
        [switch]
        $SkipDriverCheck,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
        $recordersByPath = @{}
        $moveSucceeded = $false
    }

    process {
        $recordersByPath[$DestinationRecorder.Path] = $DestinationRecorder
        foreach ($hw in $Hardware) {
            try {
                if ($null -eq $recordersByPath[$hw.ParentItemPath]) {
                    $recordersByPath[$hw.ParentItemPath] = Get-VmsRecordingServer | Where-Object Path -EQ $hw.ParentItemPath
                }
    
                if ($DestinationRecorder.Path -eq $hw.ParentItemPath) {
                    Write-Error "Hardware '$($hw.Name)' is already assigned to recorder '$($DestinationRecorder.Name)'." -TargetObject $hw
                    continue
                }
    
                if (-not $SkipDriverCheck) {
                    $srcDriver = $recordersByPath[$hw.ParentItemPath].HardwareDriverFolder.HardwareDrivers | Where-Object Path -EQ $hw.HardwareDriverPath
                    $dstDriver = $DestinationRecorder.HardwareDriverFolder.HardwareDrivers | Where-Object Path -EQ $hw.HardwareDriverPath
                    if ($null -eq $srcDriver) {
                        Write-Error "The current driver for hardware '$($hw.Name)' can not be determined."
                        continue
                    }
                    if ($null -eq $dstDriver) {
                        Write-Error "Destination recording server '$($DestinationRecorder.Name)' does not appear to have the following driver installed: $($srcDriver.Name) ($($srcDriver.Number))."
                        continue
                    }
                    if ("$($srcDriver.DriverVersion).$($srcDriver.DriverRevision)" -cne "$($dstDriver.DriverVersion).$($dstDriver.DriverRevision)") {
                        Write-Error "Destination recording server '$($DestinationRecorder.Name)' does not have the same driver version as source recording server '$($recordersByPath[$hw.ParentItemPath].Name)': Source = '$($srcDriver.DriverVersion), $($srcDriver.DriverRevision)', Destination = '$($dstDriver.DriverVersion), $($dstDriver.DriverRevision)'."
                        continue
                    }
                    Write-Verbose "Device pack driver versions and revisions match for driver '$($srcDriver.Name)': Source = '$($srcDriver.DriverVersion), $($srcDriver.DriverRevision)', Destination = '$($dstDriver.DriverVersion), $($dstDriver.DriverRevision)'."
                }
    
                if ($PSCmdlet.ShouldProcess($hw.Name, "Move hardware to $($DestinationRecorder.Name) / $($DestinationStorage.Name)")) {
                    $taskInfo = $hw.MoveHardware()
                    $taskInfo.SetProperty('DestinationRecordingServer', $DestinationRecorder.Path)
                    $taskInfo.SetProperty('DestinationStorage', $DestinationStorage.Path)
                    $taskInfo.SetProperty('ignoreSourceRecordingServer', $AllowDataLoss)
                    $result = $taskInfo.ExecuteDefault() | Wait-VmsTask -Cleanup
                    $errorText = ($result.Properties | Where-Object Key -EQ 'ErrorText').Value
                    if (-not [string]::IsNullOrWhiteSpace($errorText)) {
                        throw $errorText
                    }
                    $moveSucceeded = $true
    
                    foreach ($property in $result.Properties) {
                        if ($property.Key -match 'Warning' -and -not [string]::IsNullOrWhiteSpace($property.Value)) {
                            Write-Warning $property.Value
                        }
                    }
                }
                if ($PassThru) {
                    Get-VmsHardware -Id $hw.Id
                }
            } catch {
                throw
            }
        }
    }

    end {
        if ($moveSucceeded) {
            foreach ($recorder in $recordersByPath.Values) {
                Write-Verbose "Clearing HardwareFolder cache for $($recorder.Name)"
                $recorder.HardwareFolder.ClearChildrenCache()
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Move-VmsHardware -ParameterName DestinationRecorder -ScriptBlock {
    $recorders = Get-VmsRecordingServer | Select-Object -ExpandProperty Name -Unique | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $recorders
}

Register-ArgumentCompleter -CommandName Move-VmsHardware -ParameterName DestinationStorage -ScriptBlock {
    $recorder = $null
    if ($null -eq ($recorder = $args[4]['DestinationRecorder'] -as [VideoOS.Platform.ConfigurationItems.RecordingServer])) {
        $recorder = Get-VmsRecordingServer | Where-Object Name -eq "$($args[4]['DestinationRecorder'])"
        if ($null -eq $recorder -or $recorder.Count -ne 1) {
            return
        }
    }
    $storages = $recorder | Get-VmsStorage | Select-Object -ExpandProperty Name -Unique | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $storages
}
function Assert-VmsLicensedFeature {
    [CmdletBinding()]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
        [string]
        $Name
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        if (-not (Test-VmsLicensedFeature -Name $Name)) {
            $e = [VideoOS.Platform.NotSupportedMIPException]::new("The feature ""$Name"" is not enabled on your VMS.")
            Write-Error -Message $e.Message -Exception $e -Category NotEnabled -TargetObject $Name
        }
    }
}

Register-ArgumentCompleter -CommandName Assert-VmsLicensedFeature -ParameterName Name -ScriptBlock {
    $values = (Get-VmsSystemLicense).FeatureFlags | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsSystemLicense {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.License.SystemLicense])]
    [RequiresVmsConnection()]
    param ()

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        [MilestonePSTools.Connection.MilestoneConnection]::Instance.SystemLicense
    }
}
function Test-VmsLicensedFeature {
    [CmdletBinding()]
    [OutputType([bool])]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
        [string]
        $Name
    )

    begin {
        Assert-VmsRequirementsMet
        $license = Get-VmsSystemLicense
    }

    process {
        $license.IsFeatureEnabled($Name)
    }
}

Register-ArgumentCompleter -CommandName Test-VmsLicensedFeature -ParameterName Name -ScriptBlock {
    $values = (Get-VmsSystemLicense).FeatureFlags | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-LicenseDetails {
    [CmdletBinding()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.2')]
    [OutputType([VideoOS.Platform.ConfigurationItems.LicenseDetailChildItem])]
    param ()

    begin {
        Assert-VmsRequirementsMet
    }

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

    begin {
        Assert-VmsRequirementsMet
    }

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

    begin {
        Assert-VmsRequirementsMet
    }

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

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $licenseInfo = Get-LicenseInfo
        $licenseInfo.LicenseOverviewAllFolder.LicenseOverviewAllChildItems
    }
}
function Invoke-LicenseActivation {
    [CmdletBinding()]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.2')]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }
    
    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()]
    [RequiresVmsConnection($false)]
    param ()

    begin {
        Assert-VmsRequirementsMet
    }

    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)]
    [RequiresVmsConnection($false)]
    [RequiresElevation()]
    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-VmsRequirementsMet

        $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()]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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])]
    [MilestonePSTools.RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()
            $moduleManifest = (Get-Module MilestonePSTools).Path -replace 'psm1$', 'psd1'
            $iss.ImportPSModule($moduleManifest)
            $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
            }
        }

        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 {
                            throw
                        } 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()]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection()]
    param (
        [Parameter()]
        [RecorderNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.RecordingServer[]]
        $RecordingServer,

        [Parameter()]
        [switch]
        $IncludePlainTextPasswords,

        [Parameter()]
        [switch]
        $IncludeRetentionInfo,

        [Parameter()]
        [switch]
        $IncludeRecordingStats,

        [Parameter()]
        [switch]
        $IncludeSnapshots,

        [Parameter()]
        [ValidateRange(0, [int]::MaxValue)]
        [int]
        $SnapshotTimeoutMS = 10000,

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

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

    begin {
        Assert-VmsRequirementsMet -ErrorAction Stop
        try {
            $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-VmsSite cmdlet has a side effect of flushing all cached WCF channels.
                    Get-VmsSite | Select-VmsSite
                }
            }
            $roleMemberships = (Get-LoginSettings | Where-Object Guid -EQ (Get-VmsSite).FQID.ObjectId).GroupMembership
            $isAdmin = (Get-VmsRole -RoleType Adminstrative).Id -in $roleMemberships
            $dllFileInfo = [io.fileinfo](Get-Module MilestonePSTools)[0].Path
            $manifestPath = Join-Path $dllFileInfo.Directory.Parent.FullName 'MilestonePSTools.psd1'
            $jobRunner = [LocalJobRunner]::new($manifestPath)
            $jobRunner.JobPollingInterval = [timespan]::FromMilliseconds(500)
        } catch {
            throw
        }
    }

    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, [int]$liveTimeoutMS)
                    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 -LiveTimeoutMS $liveTimeoutMS
                        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; liveTimeoutMS = $SnapshotTimeoutMS })
            }

            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)
                    }
                }
                $endTime = Get-Date
                $startTime = $endTime.AddDays(-7)
                $recordingStatsJobs = $ids | ForEach-Object {
                    $jobRunner.AddJob($recordingStatsScript, @{Id = $_; StartTime = $startTime; EndTime = $endTime; 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 | Where-Object { ($recorderCameraMap[[guid]$_.Id]).Count } | 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-VmsRoleClaim {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('22.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
        [Alias('RoleName')]
        [ValidateNotNull()]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role[]]
        $Role,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 1)]
        [LoginProviderTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.LoginProvider]
        $LoginProvider,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 2)]
        [string]
        $ClaimName,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 3)]
        [string]
        $ClaimValue
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        foreach ($r in $Role) {
            if ($PSCmdlet.ShouldProcess("$($Role.Name)", "Add claim '$ClaimName' with value '$ClaimValue'")) {
                $null = $r.ClaimFolder.AddRoleClaim($LoginProvider.Id, $ClaimName, $ClaimValue)
            }
        }
    }
}

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

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

Register-ArgumentCompleter -CommandName Add-VmsRoleClaim -ParameterName ClaimName -ScriptBlock {
    $values = (Get-VmsLoginProvider | Get-VmsLoginProviderClaim).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Add-VmsRoleMember {
    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ByAccountName')]
    [Alias('Add-User')]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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 Copy-VmsRole {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.Role])]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role]
        $Role,

        [Parameter(Mandatory, Position = 0)]
        [string]
        $NewName
    )

    begin {
        Assert-VmsRequirementsMet
        if (Get-VmsRole -Name $NewName -ErrorAction SilentlyContinue) {
            throw "Role with name '$NewName' already exists."
            return
        }
    }

    process {
        $roleDefinition = $Role | Export-VmsRole -PassThru
        $roleDefinition.Name = $NewName
        $roleDefinition | Import-VmsRole
    }
}

Register-ArgumentCompleter -CommandName Copy-VmsRole -ParameterName Role -ScriptBlock {
    $values = (Get-VmsRole).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Export-VmsRole {
    [CmdletBinding()]
    [RequiresVmsConnection()]
    param (
        [Parameter(ValueFromPipeline)]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role[]]
        $Role,

        [Parameter()]
        [string]
        $Path,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
        if ($MyInvocation.BoundParameters.ContainsKey('Path')) {
            $resolvedPath = (Resolve-Path -Path $Path -ErrorAction SilentlyContinue -ErrorVariable rpError).Path
            if ([string]::IsNullOrWhiteSpace($resolvedPath)) {
                $resolvedPath = $rpError.TargetObject
            }
            $Path = $resolvedPath
            $fileInfo = [io.fileinfo]$Path
            if (-not $fileInfo.Directory.Exists) {
                throw ([io.directorynotfoundexception]::new("Directory not found: $($fileInfo.Directory.FullName)"))
            }
            if (($fi = [io.fileinfo]$Path).Extension -ne '.json') {
                Write-Verbose "A .json file extension will be added to the file '$($fi.Name)'"
                $Path += ".json"
            }
        } elseif (-not $MyInvocation.BoundParameters.ContainsKey('PassThru') -or -not $PassThru.ToBool()) {
            throw "Either or both of Path, or PassThru parameters must be specified."
        }

        $roles = [system.collections.generic.list[pscustomobject]]::new()

        $providers = @{}
        $supportsOidc = [version](Get-VmsManagementServer).Version -ge '22.1'
        if ($supportsOidc) {
            Get-VmsLoginProvider | Foreach-Object {
                $providers[$_.Id] = $_
            }
        }

        $clientProfiles = @{}
        (Get-VmsManagementServer).ClientProfileFolder.ClientProfiles | ForEach-Object {
            if ($null -eq $_) { return }
            $clientProfiles[$_.Path] = $_
        }

        $timeProfiles = @{
            'TimeProfile[11111111-1111-1111-1111-111111111111]' = [pscustomobject]@{
                Name        = 'Always'
                DisplayName = 'Always'
                Path        = 'TimeProfile[11111111-1111-1111-1111-111111111111]'
            }
            'TimeProfile[00000000-0000-0000-0000-000000000000]' = [pscustomobject]@{
                Name        = 'Default'
                DisplayName = 'Default'
                Path        = 'TimeProfile[00000000-0000-0000-0000-000000000000]'
            }
        }
        (Get-VmsManagementServer).TimeProfileFolder.TimeProfiles | ForEach-Object {
            if ($null -eq $_) { return }
            $timeProfiles[$_.Path] = $_
        }
    }

    process {
        if ($Role.Count -eq 0) {
            $Role = Get-VmsRole
        }

        foreach ($r in $Role) {
            $item = $r | Get-ConfigurationItem
            $clientProfile = $item | Get-ConfigurationItemProperty -Key ClientProfile -ErrorAction SilentlyContinue
            if ($clientProfile -and $clientProfiles.ContainsKey($clientProfile)) {
                $clientProfile = $clientProfiles[$clientProfile].Name
            }
            $defaultTimeProfile = $item | Get-ConfigurationItemProperty -Key RoleDefaultTimeProfile -ErrorAction SilentlyContinue
            if ($defaultTimeProfile -and $timeProfiles.ContainsKey($defaultTimeProfile)) {
                $defaultTimeProfile = $timeProfiles[$defaultTimeProfile].Name
            }
            $logonTimeProfile = $item | Get-ConfigurationItemProperty -Key RoleClientLogOnTimeProfile -ErrorAction SilentlyContinue
            if ($logonTimeProfile -and $timeProfiles.ContainsKey($logonTimeProfile)) {
                $logonTimeProfile = $timeProfiles[$logonTimeProfile].Name
            }
            $roleDto = [pscustomobject]@{
                Name                               = $r.Name
                Description                        = $r.Description
                AllowMobileClientLogOn             = $r.AllowMobileClientLogOn
                AllowSmartClientLogOn              = $r.AllowSmartClientLogOn
                AllowWebClientLogOn                = $r.AllowWebClientLogOn
                DualAuthorizationRequired          = $r.DualAuthorizationRequired
                MakeUsersAnonymousDuringPTZSession = $r.MakeUsersAnonymousDuringPTZSession
                ClientProfile                      = $clientProfile
                DefaultTimeProfile                 = $defaultTimeProfile
                ClientLogOnTimeProfile             = $logonTimeProfile
                Claims                             = [system.collections.generic.list[pscustomobject]]::new()
                Users                              = [system.collections.generic.list[pscustomobject]]::new()
                OverallSecurity                    = [system.collections.generic.list[pscustomobject]]::new()
            }
            $r.UserFolder.Users | Foreach-Object {
                $roleDto.Users.Add([pscustomobject]@{
                        Sid          = $_.Sid
                        IdentityType = $_.IdentityType
                        DisplayName  = $_.DisplayName
                        AccountName  = $_.AccountName
                        Domain       = $_.Domain
                    })
            }
            if ($supportsOidc) {
                $r | Get-VmsRoleClaim | ForEach-Object {
                    $roleDto.Claims.Add([pscustomobject]@{
                            LoginProvider = $providers[$_.ClaimProvider].Name
                            ClaimName     = $_.ClaimName
                            ClaimValue    = $_.ClaimValue
                        })
                }
            }
            
            if ($r.RoleType -eq 'UserDefined') {
                $r | Get-VmsRoleOverallSecurity | Sort-Object DisplayName | ForEach-Object {
                    $obj = [ordered]@{
                        DisplayName       = $_.DisplayName
                        SecurityNamespace = $_.SecurityNamespace
                    }
                    foreach ($key in $_.Keys | Where-Object { $_ -notin 'DisplayName', 'SecurityNamespace', 'Role' } | Sort-Object) {
                        $obj[$key] = $_[$key]
                    }
                    $roleDto.OverallSecurity.Add($obj)
                }
            }

            $roles.Add($roleDto)
            if ($PassThru) {
                $roleDto
            }
        }
    }

    end {
        if ($roles.Count -gt 0 -and $Path) {
            [io.file]::WriteAllText($Path, (ConvertTo-Json -InputObject $roles -Depth 10 -Compress), [system.text.encoding]::UTF8)
        }
    }
}

Register-ArgumentCompleter -CommandName Export-VmsRole -ParameterName Role -ScriptBlock {
    $values = (Get-VmsRole).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsRole {
    [CmdletBinding(DefaultParameterSetName = 'ByName')]
    [RequiresVmsConnection()]
    [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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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
                }
                if ($null -eq $role.ClientProfile) {
                    # Added because the ClientProfile, RoleDefaultTimeProfile, and RoleClientLogOnTimeProfile are $null
                    # when enumerating a role from the RoleFolder.Roles collection. If it's not null, then the MIP SDK
                    # behavior will have improved and we can avoid extra API calls by returning cached values.
                    [VideoOS.Platform.ConfigurationItems.Role]::new($role.ServerId, $role.Path)
                } else {
                    $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 {
    # Accessing role names from management server object because Get-VmsRole
    # is too slow due to a desire to have time and clientprofile properties filled in when using that cmdlet.
    $values = (Get-VmsManagementServer).RoleFolder.Roles.Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Get-VmsRole -ParameterName Id -ScriptBlock {
    $values = (Get-VmsManagementServer).RoleFolder.Roles.Id
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Get-VmsRole -ParameterName RoleType -ScriptBlock {
    $values = (Get-VmsManagementServer).RoleFolder.Roles[0].RoleTypeValues.Values | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsRoleClaim {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.ConfigurationItems.ClaimChildItem])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('22.1')]
    param (
        [Parameter(ValueFromPipeline, Position = 0)]
        [Alias('RoleName')]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role[]]
        $Role,

        [Parameter(Position = 1)]
        [string[]]
        $ClaimName,

        [Parameter()]
        [LoginProviderTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.LoginProvider]
        $LoginProvider
    )

    begin {
        Assert-VmsRequirementsMet
    }
    
    process {
        if ($null -eq $Role) {
            $Role = Get-VmsRole
        }
        foreach ($r in $Role) {
            $matchFound = $false
            foreach ($claim in $r.ClaimFolder.ClaimChildItems) {
                if ($MyInvocation.BoundParameters.ContainsKey('ClaimName') -and $claim.ClaimName -notin $ClaimName) {
                    continue
                }
                if ($MyInvocation.BoundParameters.ContainsKey('LoginProvider') -and $claim.ClaimProvider -ne $LoginProvider.Id) {
                    continue
                }
                $claim
                $matchFound = $true
            }
            if ($MyInvocation.BoundParameters.ContainsKey('ClaimName') -and -not $matchFound) {
                Write-Error "No claim found matching the name '$ClaimName' in role '$($r.Name)'."
            }
        }
    }
}

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

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

Register-ArgumentCompleter -CommandName Get-VmsRoleClaim -ParameterName LoginProvider -ScriptBlock {
    $values = (Get-VmsLoginProvider).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsRoleMember {
    [CmdletBinding()]
    [Alias('Get-User')]
    [OutputType([VideoOS.Platform.ConfigurationItems.User])]
    [RequiresVmsConnection()]
    param (
        [Parameter(ValueFromPipeline, Position = 0)]
        [Alias('RoleName')]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role[]]
        $Role
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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])]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [Alias('RoleName')]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role]
        $Role,

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

    begin {
        Assert-VmsRequirementsMet
        $namespacevalues = Get-SecurityNamespaceValues
        if ($SecurityNamespace.Count -eq 0) {
            $SecurityNamespace = $namespacevalues.SecurityNamespacesById.Keys
        }
    }

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

        try {
            foreach ($namespace in $SecurityNamespace) {
                $response = $Role.ChangeOverallSecurityPermissions($namespace)
                $result = @{
                    Role        = $Role.Path
                    DisplayName = $namespacevalues.SecurityNamespacesById[$namespace]
                }
                foreach ($key in $response.GetPropertyKeys()) {
                    $result[$key] = $response.GetProperty($key)
                }
                # :: milestonesystemsinc/powershellsamples/issue-81
                # Older VMS versions may not include a SecurityNamespace value
                # in the ChangeOverallSecurityPermissions properties which means
                # you can't pass this hashtable into Set-VmsRoleOverallSecurity
                # without explicity including the namespace parameter. So we'll
                # manually add it here just in case it's not already set.
                $result['SecurityNamespace'] = $namespace.ToString()
                $result
            }
        } catch {
            Write-Error -ErrorRecord $_
        }
    }
}


Register-ArgumentCompleter -CommandName Get-VmsRoleOverallSecurity -ParameterName Role -ScriptBlock {
    $values = ((Get-VmsManagementServer).RoleFolder.Roles | Where-Object RoleType -EQ 'UserDefined').Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Get-VmsRoleOverallSecurity -ParameterName SecurityNamespace -ScriptBlock {
    $values = (Get-SecurityNamespaceValues).SecurityNamespacesByName.Keys | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Import-VmsRole {
    [CmdletBinding(DefaultParameterSetName = 'Path', SupportsShouldProcess)]
    [RequiresVmsConnection()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'InputObject')]
        [object[]]
        $InputObject,

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

        [Parameter()]
        [switch]
        $Force,

        [Parameter()]
        [switch]
        $RemoveUndefinedClaims,

        [Parameter()]
        [switch]
        $RemoveUndefinedUsers
    )

    begin {
        Assert-VmsRequirementsMet
        $null = Get-VmsManagementServer -ErrorAction Stop

        if ($MyInvocation.BoundParameters.ContainsKey('Path')) {
            $resolvedPath = (Resolve-Path -Path $Path -ErrorAction SilentlyContinue -ErrorVariable rpError).Path
            if ([string]::IsNullOrWhiteSpace($resolvedPath)) {
                $resolvedPath = $rpError.TargetObject
            }
            $Path = $resolvedPath
            $fileInfo = [io.fileinfo]$Path
            if (-not $fileInfo.Directory.Exists) {
                throw ([io.directorynotfoundexception]::new("Directory not found: $($fileInfo.Directory.FullName)"))
            }
            if (($fi = [io.fileinfo]$Path).Extension -ne '.json') {
                Write-Verbose "A .json file extension will be added to the file '$($fi.Name)'"
                $Path += ".json"
            }
        }


        $roles = @{}
        (Get-VmsManagementServer).RoleFolder.ClearChildrenCache()
        Get-VmsRole | Foreach-Object {
            if ($roles.ContainsKey($_.Name)) {
                throw "There are multiple existing roles with the same case-insensitive name '$($_.Name)'. The VMS may allow this, but this cmdlet does not. Please consider renaming roles so that they all have unique names."
            }
            $roles[$_.Name] = $_
        }

        

        $providers = @{}
        $supportsOidc = [version](Get-VmsManagementServer).Version -ge '22.1'
        if ($supportsOidc) {
            Get-VmsLoginProvider | Foreach-Object {
                if ($null -eq $_) { return }
                $providers[$_.Name] = $_
            }
        }

        $clientProfiles = @{}
        (Get-VmsManagementServer).ClientProfileFolder.ClientProfiles | ForEach-Object {
            if ($null -eq $_) { return }
            $clientProfiles[$_.Name] = $_
        }

        $timeProfiles = @{
            'Always' = [pscustomobject]@{
                Name        = 'Always'
                DisplayName = 'Always'
                Path        = 'TimeProfile[11111111-1111-1111-1111-111111111111]'
            }
            'Default' = [pscustomobject]@{
                Name        = 'Default'
                DisplayName = 'Default'
                Path        = 'TimeProfile[00000000-0000-0000-0000-000000000000]'
            }
        }
        (Get-VmsManagementServer).TimeProfileFolder.TimeProfiles | ForEach-Object {
            if ($null -eq $_) { return }
            $timeProfiles[$_.Name] = $_
        }

        $basicUsers = @{}
        Get-VmsBasicUser -External:$false | ForEach-Object {
            $basicUsers[$_.Name] = $_
        }
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'Path') {
            $InputObject = [io.file]::ReadAllText($Path, [text.encoding]::UTF8) | ConvertFrom-Json -ErrorAction Stop
        }

        foreach ($dto in $InputObject) {
            if ([string]::IsNullOrWhiteSpace($dto.Name)) {
                Write-Error -Message "Record does not have a 'Name' property, the minimum required information to create a new role." -TargetObject $dto
                continue
            }
            $role = $roles[$dto.Name]
            if ($role -and -not $Force) {
                Write-Warning "Role '$($dto.Name)' already exists. To import changes to existing roles, use the -Force switch."
                continue
            }

            $roleParams = @{
                ErrorAction = 'Stop'
            }
            foreach ($propertyName in 'Name', 'Description', 'AllowSmartClientLogOn', 'AllowMobileClientLogOn', 'AllowWebClientLogOn', 'DualAuthorizationRequired', 'MakeUsersAnonymousDuringPTZSession', 'ClientLogOnTimeProfile', 'DefaultTimeProfile', 'ClientProfile') {
                $propertyValue = $dto.$propertyName
                if ($propertyName -in @('DefaultTimeProfile', 'ClientLogOnTimeProfile')) {
                    if ($propertyValue -ne 'Always' -and $propertyValue -ne 'Default') {
                        # The default "Always" and "<default>" time profiles are not actually a time profile defined in (Get-VmsManagementServer).TimeProfileFolder.TimeProfiles
                        # but the TimeProfileNameTransformAttribute class will accept 'Always' or 'Default' as a value and mock up a TimeProfile object for us.
                        $propertyValue = $timeProfiles[$propertyValue]
                    }
                }
                if ($propertyName -eq 'ClientProfile' -and -not $clientProfiles.ContainsKey($dto.ClientProfile)) {
                    $propertyValue = $null
                }
                if ($null -ne $propertyValue -or $propertyName -eq 'Description') {
                    $roleParams[$propertyName] = $propertyValue
                } else {
                    Write-Warning "Skipping property '$propertyName'. Unable to resolve the value '$($dto.$propertyName)'."
                }
            }

            # Create/update the main role properties
            if ($role) {
                $roleParams.Role = $role
                $roleParams.PassThru = $true
                $role = Set-VmsRole @roleParams
            }
            else {
                $role = New-VmsRole @roleParams
            }

            # Update overall security for all roles except default admin role
            if ($role.RoleType -eq 'UserDefined') {
                foreach ($definition in $dto.OverallSecurity) {
                    $permissions = $definition
                    if ($permissions -isnot [System.Collections.IDictionary]) {
                        $permissions = @{}
                        ($definition | Get-Member -MemberType NoteProperty).Name | ForEach-Object {
                            $permissions[$_] = $definition.$_
                        }
                    }
                    $role | Set-VmsRoleOverallSecurity -Permissions $permissions
                }
            }

            # Update the role members, and claims
            if ($supportsOidc) {
                $existingClaims = @()
                $role | Get-VmsRoleClaim | ForEach-Object {
                    $existingClaims += $_
                }
                foreach ($claim in $dto.Claims) {
                    if ([string]::IsNullOrWhiteSpace($claim.LoginProvider) -or -not $providers.ContainsKey($claim.LoginProvider)) {
                        Write-Warning "Skipping claim '$($claim.ClaimName)'. Unable to resolve LoginProvider value '$($claim.LoginProvider)'."
                        continue
                    }
                    $provider = $providers[$claim.LoginProvider]
                    $registeredClaims = ($provider | Get-VmsLoginProviderClaim).Name
                    if ($claim.ClaimName -notin $registeredClaims) {
                        Write-Verbose "Adding '$($claim.ClaimName)' as a new registered claim."
                        $provider | Add-VmsLoginProviderClaim -Name $claimName
                    }
                    if ($null -eq ($existingClaims | Where-Object {$_.ClaimProvider -eq $provider.Id -and $_.ClaimName -eq $claim.ClaimName -and $_.ClaimValue -eq $claim.ClaimValue })) {
                        $role | Add-VmsRoleClaim -LoginProvider $provider -ClaimName $claim.ClaimName -ClaimValue $claim.ClaimValue
                        $existingClaims += [pscustomobject]@{
                            ClaimProvider = $provider.Id
                            ClaimName     = $claim.ClaimName
                            ClaimValue    = $claim.ClaimValue
                        }
                    }
                }
                if ($RemoveUndefinedClaims) {
                    foreach ($claim in $existingClaims) {
                        $provider = Get-VmsLoginProvider | Where-Object Id -eq $claim.ClaimProvider
                        $definedClaims = $dto.Claims | Where-Object { $_.LoginProvider -eq $provider.Name -and $_.ClaimName -eq $claim.ClaimName -and $_.ClaimValue -eq $claim.ClaimValue }
                        if ($null -eq $definedClaims) {
                            $role | Remove-VmsRoleClaim -LoginProvider $provider -ClaimName $claim.ClaimName -ClaimValue $claim.ClaimValue
                        }
                    }
                }
            }

            $existingUsers = @{}
            $role | Get-VmsRoleMember | ForEach-Object {
                $existingUsers[$_.Sid] = $null
            }
            foreach ($user in $dto.Users) {
                if ($user.Sid -and -not $existingUsers.ContainsKey($user.Sid)) {
                    if ($user.IdentityType -eq 'BasicUser') {
                        if ($basicUsers.ContainsKey($user.AccountName)) {
                            $user.Sid = $basicUsers[$user.AccountName].Sid
                        } else {
                            try {
                                $passwordChars = [System.Web.Security.Membership]::GeneratePassword(26, 10).ToCharArray() + (Get-Random -Minimum 1000 -Maximum 10000).ToString().ToCharArray()
                                $randomPassword = [securestring]::new()
                                ($passwordChars | Get-Random -Count ($passwordChars.Length)) | ForEach-Object { $randomPassword.AppendChar($_) }
                                $newUser = New-VmsBasicUser -Name $user.AccountName -Password $randomPassword -Status LockedOutByAdmin
                                $basicUsers[$newUser.Name] = $newUser
                                $user.Sid = $newUser.Sid
                            } finally {
                                0..($passwordChars.Length - 1) | ForEach-Object { $passwordChars[$_] = 0 }
                                Remove-Variable -Name passwordChars
                            }
                        }
                    }
                    $role | Add-VmsRoleMember -Sid $user.Sid
                    $existingUsers[$user.Sid] = $null
                }
            }
            if ($RemoveUndefinedUsers) {
                foreach ($sid in $existingUsers.Keys | Where-Object { $_ -notin $dto.Users.Sid}) {
                    $role | Remove-VmsRoleMember -Sid $sid
                }
            }

            $role
        }
    }
}
function New-VmsRole {
    [CmdletBinding(SupportsShouldProcess)]
    [Alias('Add-Role')]
    [OutputType([VideoOS.Platform.ConfigurationItems.Role])]
    [RequiresVmsConnection()]
    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(ValueFromPipelineByPropertyName)]
        [ClientProfileTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.ClientProfile]
        $ClientProfile,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        try {
            $ms = Get-VmsManagementServer -ErrorAction Stop
            if (-not $PSCmdlet.ShouldProcess("$($ms.Name) ($($ms.ServerId.Uri))", "Create role '$Name'")) {
                return
            }

            $serverTask = $ms.RoleFolder.AddRole(
                $Name, $Description,
                $DualAuthorizationRequired,
                $MakeUsersAnonymousDuringPTZSession,
                $AllowMobileClientLogOn, $AllowSmartClientLogOn, $AllowWebClientLogOn,
                $DefaultTimeProfile.Path, $ClientLogOnTimeProfile.Path)

            if ($serverTask.State -ne 'Success') {
                throw "RoleFolder.AddRole(..) state: $($serverTask.State). Error: $($serverTask.ErrorText)"
            }

            $newRole = [VideoOS.Platform.ConfigurationItems.Role]::new($ms.ServerId, $serverTask.Path)
            if ($MyInvocation.BoundParameters.ContainsKey('ClientProfile')) {
                $newRole | Set-VmsRole -ClientProfile $ClientProfile
            }

            <#
                TFS 540814 / 577523: On 2022 R2 and earlier, time profile paths were ignored during role creation and you needed to set these after creating the role.
            #>

            $dirty = $false
            if ($MyInvocation.BoundParameters.ContainsKey('ClientLogOnTimeProfile') -and $newRole.RoleClientLogOnTimeProfile -ne $ClientLogOnTimeProfile.Path) {
                $newRole.RoleClientLogOnTimeProfile = $ClientLogOnTimeProfile.Path
                $dirty = $true
            }
            if ($MyInvocation.BoundParameters.ContainsKey('DefaultTimeProfile') -and $newRole.RoleDefaultTimeProfile -ne $DefaultTimeProfile.Path) {
                $newRole.RoleDefaultTimeProfile = $DefaultTimeProfile.Path
                $dirty = $true
            }
            if ($dirty) {
                $null = $newRole.Save()
            }


            $newRole
            if ($PassThru) {
                Write-Verbose "NOTICE: The PassThru parameter is deprecated as of MilestonePSTools v23.1.2. The new role is now always returned."
            }
        } catch {
            if ($_.Exception.Message) {
                Write-Error -Message $_.Exception.Message -Exception $_.Exception
            } else {
                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
}

Register-ArgumentCompleter -CommandName New-VmsRole -ParameterName ClientProfile -ScriptBlock {
    $values = (Get-VmsClientProfile).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Remove-VmsRole {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'ByName')]
    [Alias('Remove-Role')]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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-VmsRoleClaim {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('22.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
        [Alias('RoleName')]
        [ValidateNotNull()]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role[]]
        $Role,

        [Parameter(ValueFromPipelineByPropertyName, Position = 1)]
        [Alias('ClaimProvider')]
        [LoginProviderTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.LoginProvider]
        $LoginProvider,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 2)]
        [string[]]
        $ClaimName,

        [Parameter(ValueFromPipelineByPropertyName)]
        [string]
        $ClaimValue
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        foreach ($r in $Role) {
            $claims = $r | Get-VmsRoleClaim | Where-Object ClaimName -in $ClaimName
            if ($claims.Count -eq 0) {
                Write-Error "No matching claims found on role $($r.Name)."
                continue
            }
            foreach ($c in $claims) {
                if (-not [string]::IsNullOrWhiteSpace($ClaimValue) -and $c.ClaimValue -ne $ClaimValue) {
                    continue
                }
                if ($null -ne $LoginProvider -and $c.ClaimProvider -ne $LoginProvider.Id) {
                    continue
                }
                try {
                    if ($PSCmdlet.ShouldProcess("Claim '$($c.ClaimName)' on role '$($r.Name)'", "Remove")) {
                        $null = $r.ClaimFolder.RemoveRoleClaim($c.ClaimProvider, $c.ClaimName, $c.ClaimValue)
                    }
                } catch {
                    Write-Error -Message $_.Exception.Message -TargetObject $c
                }
            }
        }
    }
}

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

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

Register-ArgumentCompleter -CommandName Remove-VmsRoleClaim -ParameterName ClaimName -ScriptBlock {
    $values = (Get-VmsLoginProvider | Get-VmsLoginProviderClaim).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Remove-VmsRoleMember {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'ByUser')]
    [Alias('Remove-User')]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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 Set-VmsRole {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.Platform.ConfigurationItems.Role])]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [RoleNameTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.Role[]]
        $Role,

        [Parameter()]
        [string]
        $Name,

        [Parameter()]
        [string]
        $Description,

        [Parameter()]
        [switch]
        $AllowSmartClientLogOn,

        [Parameter()]
        [switch]
        $AllowMobileClientLogOn,

        [Parameter()]
        [switch]
        $AllowWebClientLogOn,

        [Parameter()]
        [switch]
        $DualAuthorizationRequired,

        [Parameter()]
        [switch]
        $MakeUsersAnonymousDuringPTZSession,

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

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

        [Parameter()]
        [ClientProfileTransformAttribute()]
        [VideoOS.Platform.ConfigurationItems.ClientProfile]
        $ClientProfile,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $dirty = $false
        foreach ($r in $Role) {
            try {
                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 ($MyInvocation.BoundParameters.ContainsKey('ClientProfile') -and $PSCmdlet.ShouldProcess($r.Name, "Set ClientProfile to $($ClientProfile.Name)")) {
                    try {
                        $serverTask = $r.SetClientProfile($ClientProfile.Path)
                        if ($serverTask.State -ne 'Success') {
                            Write-Error -Message "Failed to update ClientProfile. $($serverTask.ErrorText)" -TargetObject $r
                        }
                    } catch {
                        Write-Error -Message $_.Exception.Message -Exception $_.Exception -TargetObject $r
                    }
                }

                if ($dirty) {
                    $r.Save()
                }
                if ($PassThru) {
                    $r
                }
            } catch {
                Write-Error -Message $_.Exception.Message -Exception $_.Exception -TargetObject $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 = @('Always')
    (Get-VmsManagementServer).TimeProfileFolder.TimeProfiles.Name | Sort-Object | Foreach-Object {
        $values += $_
    }
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Set-VmsRole -ParameterName ClientLogOnTimeProfile -ScriptBlock {
    $values = @('Always')
    (Get-VmsManagementServer).TimeProfileFolder.TimeProfiles.Name | Sort-Object | Foreach-Object {
        $values += $_
    }
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

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

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

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

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        if ($null -eq $Role) {
            $roleId = Split-VmsConfigItemPath -Path $Permissions.Role
            if ([string]::IsNullOrEmpty($roleId)) {
                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
            }
            $Role = Get-VmsRole -Id $roleId
        }

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

        if (-not $PSCmdlet.MyInvocation.BoundParameters.ContainsKey('SecurityNamespace') -and $null -eq ($SecurityNamespace = $Permissions.SecurityNamespace -as [guid])) {
            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
        }

        try {
            $invokeInfo = $Role.ChangeOverallSecurityPermissions($SecurityNamespace)
            $attributes = @{}
            $invokeInfo.GetPropertyKeys() | ForEach-Object { $attributes[$_] = $invokeInfo.GetProperty($_) }
            if ($attributes.Count -eq 0) {
                Write-Error "No security attribute key/value pairs were returned for namespace ID '$SecurityNamespace'." -TargetObject $invokeInfo
                return
            }
            $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 [VideoOS.Platform.Proxy.ConfigApi.ValidateResultException] {
            $_ | HandleValidateResultException -TargetObject $Role
        } catch {
            Write-Error -ErrorRecord $_
        }
    }
}


Register-ArgumentCompleter -CommandName Set-VmsRoleOverallSecurity -ParameterName Role -ScriptBlock {
    $values = ((Get-VmsManagementServer).RoleFolder.Roles | Where-Object RoleType -EQ 'UserDefined').Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}

Register-ArgumentCompleter -CommandName Set-VmsRoleOverallSecurity -ParameterName SecurityNamespace -ScriptBlock {
    $values = (Get-SecurityNamespaceValues).SecurityNamespacesByName.Keys | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Export-VmsRule {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    [RequiresVmsConnection()]
    param (
        [Parameter(ValueFromPipeline)]
        [RuleNameTransformAttribute()]
        [ValidateVmsItemType('Rule')]
        [VideoOS.ConfigurationApi.ClientService.ConfigurationItem[]]
        $Rule,

        [Parameter(Position = 0)]
        [string]
        $Path,

        [Parameter()]
        [switch]
        $PassThru,

        [Parameter()]
        [switch]
        $Force
    )

    begin {
        Assert-VmsRequirementsMet
        if ($MyInvocation.BoundParameters.ContainsKey('Path')) {
            $resolvedPath = (Resolve-Path -Path $Path -ErrorAction SilentlyContinue -ErrorVariable rpError).Path
            if ([string]::IsNullOrWhiteSpace($resolvedPath)) {
                $resolvedPath = $rpError.TargetObject
            }
            $Path = $resolvedPath
            $fileInfo = [io.fileinfo]$Path
            if (-not $fileInfo.Directory.Exists) {
                throw ([io.directorynotfoundexception]::new("Directory not found: $($fileInfo.Directory.FullName)"))
            }
            if ($fileInfo.Extension -ne '.json') {
                Write-Verbose "A .json file extension will be added to the file '$($fi.Name)'"
                $Path += ".json"
            }
            if ((Test-Path -Path $Path) -and -not $Force) {
                throw ([System.IO.IOException]::new("The file '$Path' already exists. Include the -Force switch to overwrite an existing file."))
            }
        } elseif (-not $MyInvocation.BoundParameters.ContainsKey('PassThru') -or -not $PassThru.ToBool()) {
            throw "Either or both of Path, or PassThru parameters must be specified."
        }
        $rules = @{}
    }

    process {
        if ($Rule.Count -eq 0) {
            $Rule = Get-VmsRule
        }
        foreach ($currentRule in $Rule) {
            $obj = [pscustomobject]@{
                DisplayName = $currentRule.DisplayName
                Enabled     = $currentRule.EnableProperty.Enabled
                Id          = [guid]$currentRule.Path.Substring(5, 36)
                Properties  = [pscustomobject[]]@($currentRule.Properties | Foreach-Object {
                        $prop = $_
                        [pscustomobject]@{
                            DisplayName    = $prop.DisplayName
                            Key            = $prop.Key
                            Value          = $prop.Value
                            ValueType      = $prop.ValueType
                            ValueTypeInfos = [pscustomobject[]]@($prop.ValueTypeInfos | Select-Object @{Name = 'Key'; Expression = { $prop.Key } }, Name, Value)
                            IsSettable     = $prop.IsSettable
                        }
                    })
            }

            $duplicateCount = 0
            $baseName = $obj.DisplayName -replace ' DUPLICATE \d+$', ''
            while ($rules.ContainsKey($obj.DisplayName)) {
                $duplicateCount++
                $obj.DisplayName = $baseName + " DUPLICATE $duplicateCount"
                $obj.Properties | Where-Object Key -eq 'Name' | ForEach-Object { $_.Value = $obj.DisplayName }
            }
            $rules[$obj.DisplayName] = $obj
            if ($duplicateCount) {
                Write-Warning "There are multiple rules named '$baseName'. Duplicates will be renamed."
            }

            if ($PassThru) {
                $obj
            }
        }
    }

    end {
        if ($rules.Count -and $Path) {
            Write-Verbose "Saving $($rules.Count) exported rules in JSON format to $Path"
            [io.file]::WriteAllText($Path, (ConvertTo-Json -InputObject $rules.Values -Depth 10 -Compress), [system.text.encoding]::UTF8)
        }
    }
}
function Get-VmsRule {
    [CmdletBinding(DefaultParameterSetName = 'Name')]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.1')]
    param (
        [Parameter(ParameterSetName = 'Name', ValueFromPipelineByPropertyName, Position = 0)]
        [Alias('DisplayName')]
        [SupportsWildcards()]
        [string]
        $Name = '*',

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

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        try {
            switch ($PSCmdlet.ParameterSetName) {
                'Name' {
                    $matchFound = $false
                    Get-ConfigurationItem -Path /RuleFolder -ChildItems -ErrorAction Stop | Where-Object DisplayName -like $Name | Foreach-Object {
                        $matchFound = $true
                        $_
                    }
                    if (-not $matchFound -and -not [System.Management.Automation.WildcardPattern]::ContainsWildcardCharacters($Name)) {
                        Write-Error "Rule with DisplayName '$($Name)' not found."
                    }
                }

                'Id' {
                    Get-ConfigurationItem -Path "Rule[$Id]" -ErrorAction Stop
                }
            }

        } catch {
            if ($null -eq (Get-ConfigurationItem -Path / -ChildItems | Where-Object Path -eq '/RuleFolder')) {
                Write-Error "The current VMS version does not support management of rules using configuration api."
            } elseif ($_.FullyQualifiedErrorId -match 'PathNotFoundExceptionFault') {
                Write-Error "Rule with Id '$Id' not found."
            } else {
                Write-Error -Message $_.Exception.Message -Exception $_.Exception
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsRule -ParameterName Name -ScriptBlock {
    $values = (Get-VmsRule).DisplayName | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Import-VmsRule {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.ConfigurationApi.ClientService.ConfigurationItem])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'FromObject')]
        [ValidateScript({
                $members = $_ | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name
                foreach ($member in @('DisplayName', 'Enabled', 'Id', 'Properties')) {
                    if ($member -notin $members) {
                        throw "InputObject is missing member named '$member'"
                    }
                }
                $true
            })]
        [object[]]
        $InputObject,

        [Parameter(Mandatory, Position = 0, ParameterSetName = 'FromFile')]
        [string]
        $Path
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        try {
            $progressParams = @{
                Activity        = 'Importing rules'
                PercentComplete = 0
            }
            Write-Progress @progressParams
            if ($PSCmdlet.ParameterSetName -eq 'FromFile') {
                $Path = (Resolve-Path -Path $Path -ErrorAction Stop).Path
                $InputObject = [io.file]::ReadAllText($Path, [text.encoding]::UTF8) | ConvertFrom-Json
            }
            $total = $InputObject.Count
            $processed = 0
            foreach ($exportedRule in $InputObject) {
                try {
                    $progressParams.CurrentOperation = "Importing rule '$($exportedRule.DisplayName)'"
                    $progressParams.PercentComplete = $processed / $total * 100
                    $progressParams.Status = ($progressParams.PercentComplete / 100).ToString('p0')
                    Write-Progress @progressParams

                    if ($PSCmdlet.ShouldProcess($exportedRule.DisplayName, "Create rule")) {
                        $newRule = $exportedRule | New-VmsRule -ErrorAction Stop
                        $newRule
                    }
                } catch {
                    Write-Error -ErrorRecord $_
                } finally {
                    $processed++
                }
            }
        } finally {
            $progressParams.Completed = $true
            $progressParams.PercentComplete = 100
            Write-Progress @progressParams
        }
    }
}
function New-VmsRule {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([VideoOS.ConfigurationApi.ClientService.ConfigurationItem])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.1')]
    param (
        [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)]
        [Alias('DisplayName')]
        [string]
        $Name,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [PropertyCollectionTransformAttribute()]
        [hashtable]
        $Properties,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('EnableProperty')]
        [BooleanTransformAttribute()]
        [bool]
        $Enabled = $true
    )

    begin {
        Assert-VmsRequirementsMet
        $ruleFolder = Get-ConfigurationItem -Path /RuleFolder
    }

    process {
        if (-not $PSCmdlet.ShouldProcess($Name, "Create rule")) {
            return
        }
        $invokeInfo = $null
        try {
            $Properties['Name'] = $Name
            $invokeInfo = $ruleFolder | Invoke-Method -MethodId AddRule
            $lastPropertyCount = $invokeInfo.Properties.Count
            $iteration = 0
            $maxIterations = 20
            $filledProperties = @{ Id = $null }
            do {
                if ((++$iteration) -ge $maxIterations) {
                    $propertyDump = ($invokeInfo.Properties | Select-Object Key, Value, @{Name = 'ValueTypeInfos'; Expression = { $_.ValueTypeInfos.Value -join '|'}}) | Format-Table | Out-String
                    Write-Verbose "InvokeInfo Properties:`r`n$propertyDump"

                    $exception = [invalidoperationexception]::new("Maximum request/response iterations reached while creating rule. This can happen when the supplied properties hashtable is missing important key/value pairs or when a provided value is incorrect. Inspect the 'Properties' collection on the TargetObject property on this ErrorRecord.")
                    $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, $exception.Message, [System.Management.Automation.ErrorCategory]::InvalidData, $invokeInfo)
                    throw $errorRecord
                }
                try {
                    foreach ($key in $invokeInfo.Properties.Key) {
                        # Skip key if already set in a previous iteration
                        if ($filledProperties.ContainsKey($key)) {
                            continue
                        } else {
                            $filledProperties[$key] = $null
                        }

                        # If imported rule definition doesn't have a property that the configuration api has,
                        # we might be able to finish creating the rule, or we might end up in a perpetual loop
                        # until we reach $maxIterations and fail.
                        if (-not $Properties.ContainsKey($key)) {
                            Write-Verbose "Property with key '$key' not provided in Properties hashtable for new rule '$($Name)'."
                            continue
                        }

                        # Protect against null or empty property values
                        if ([string]::IsNullOrWhiteSpace($Properties[$key])) {
                            continue
                        }
                        $newRuleProperty = $invokeInfo.Properties | Where-Object Key -eq $key
                        switch ($newRuleProperty.ValueType) {
                            'Enum' {
                                # Use the enum value with the same supplied value using case-insensitive comparison
                                $newValue = ($newRuleProperty.ValueTypeInfos | Where-Object Value -eq $Properties[$key]).Value
                                if ($null -eq $newValue) {
                                    # The user-supplied value doesn't match any enum values so compare against the enum value display names
                                    $newValue = ($newRuleProperty.ValueTypeInfos | Where-Object Name -eq $Properties[$key]).Value
                                    if ($null -eq $newValue) {
                                        Write-Warning "Value for user-supplied property '$key' does not match the available options: $($newRuleProperty.ValueTypeInfos.Value -join ', ')."
                                        $newValue = $Properties[$key]
                                    } else {
                                        Write-Verbose "Value for user-supplied property '$key' has been mapped from '$($Properties[$key])' to '$newValue'"
                                    }
                                }
                                $Properties[$key] = $newValue
                            }
                        }
                        $invokeInfo | Set-ConfigurationItemProperty -Key $key -Value $Properties[$key]
                    }

                    $response = $invokeInfo | Invoke-Method AddRule -ErrorAction Stop
                    $invokeInfo = $response
                    $newPropertyCount = $invokeInfo.Properties.Count
                    if ($lastPropertyCount -ge $newPropertyCount -and $null -eq ($invokeInfo | Get-ConfigurationItemProperty -Key 'State' -ErrorAction SilentlyContinue)) {
                        $exception = [invalidoperationexception]::new("Invalid rule definition. Inspect the properties of the InvokeInfo object in this error's TargetObject property. This is commonly a result of creating a rule using the ID of an object that does not exist.")
                        $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, $exception.Message, [System.Management.Automation.ErrorCategory]::InvalidData, $invokeInfo)
                        throw $errorRecord
                    }
                    $lastPropertyCount = $newPropertyCount
                } catch {
                    throw
                }
            } while ($invokeInfo.ItemType -eq 'InvokeInfo')

            if (($invokeInfo | Get-ConfigurationItemProperty -Key State) -ne 'Success') {
                $exception = [invalidoperationexception]::new("Error in New-VmsRule: $($invokeInfo | Get-ConfigurationItemProperty -Key 'ErrorText' -ErrorAction SilentlyContinue)")
                $errorRecord = [System.Management.Automation.ErrorRecord]::new($_.Exception, $_.Exception.Message, [System.Management.Automation.ErrorCategory]::InvalidData, $invokeInfo)
                throw $errorRecord
            }

            $newRuleId = ($invokeInfo | Get-ConfigurationItemProperty -Key Path) -replace 'Rule\[(.+)\]', '$1'
            $newRule = Get-VmsRule -Id $newRuleId -ErrorAction Stop

            if ($Enabled -ne $newRule.EnableProperty.Enabled) {
                $newRule.EnableProperty.Enabled = $Enabled
                $null = $newRule | Set-ConfigurationItem
            }

            $newRule
        } catch {
            $exception = [invalidoperationexception]::new("An error occurred while creating the rule: $($_.Exception.Message)", $_.Exception)
            $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, $exception.Message, [System.Management.Automation.ErrorCategory]::InvalidData, $invokeInfo)
            Write-Error -Message $exception.Message -Exception $exception -TargetObject $invokeInfo
        }
    }
}
function Remove-VmsRule {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.1')]
    param(
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [RuleNameTransformAttribute()]
        [ValidateVmsItemType('Rule')]
        [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]
        $Rule
    )

    begin {
        Assert-VmsRequirementsMet
    }
    
    process {
        if (-not $PSCmdlet.ShouldProcess($Rule.DisplayName, 'Remove')) {
            return
        }

        try {
            $invokeInfo = Get-ConfigurationItem -Path /RuleFolder | Invoke-Method RemoveRule
            $invokeInfo | Set-ConfigurationItemProperty -Key 'RemoveRulePath' -Value $Rule.Path
            $invokeInfo = $invokeInfo | Invoke-Method RemoveRule -ErrorAction Stop
            if (($invokeInfo | Get-ConfigurationItemProperty -Key State) -ne 'Success') {
                throw "Configuration API response did not indicate success."
            }
        } catch {
            Write-Error -Message $_.Exception.Message -Exception $_.Exception -TargetObject $invokeInfo
        }
    }
}

Register-ArgumentCompleter -CommandName Remove-VmsRule -ParameterName Rule -ScriptBlock {
    $values = (Get-VmsRule).DisplayName | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Set-VmsRule {
    [CmdletBinding(SupportsShouldProcess)]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('20.1')]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [RuleNameTransformAttribute()]
        [ValidateVmsItemType('Rule')]
        [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]
        $Rule,

        [Parameter()]
        [string]
        $Name,

        [Parameter()]
        [BooleanTransformAttribute()]
        [bool]
        $Enabled,

        [Parameter()]
        [PropertyCollectionTransformAttribute()]
        [hashtable]
        $Properties,

        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $dirty = $false
        if ($MyInvocation.BoundParameters.ContainsKey('Name')) {
            $currentValue = $Rule | Get-ConfigurationItemProperty -Key Name
            if ($Name -cne $currentValue -and $PSCmdlet.ShouldProcess("Rule '$($Rule.DisplayName)'", "Set DisplayName to $Name")) {
                $Rule.DisplayName = $Name
                $Rule | Set-ConfigurationItemProperty -Key Name -Value $Name
                $dirty = $true
            }
        }
        if ($MyInvocation.BoundParameters.ContainsKey('Enabled')) {
            if ($Enabled -ne $Rule.EnableProperty.Enabled -and $PSCmdlet.ShouldProcess("Rule '$($Rule.DisplayName)'", "Set Enabled to $Enabled")) {
                $Rule.EnableProperty.Enabled = $Enabled
                $dirty = $true
            }
        }

        if ($MyInvocation.BoundParameters.ContainsKey('Properties') -and $PSCmdlet.ShouldProcess("Rule '$($Rule.DisplayName)'", "Update properties")) {
            $currentProperties = @{}
            $Rule.Properties | ForEach-Object {
                $currentProperties[$_.Key] = $_.Value
            }
            foreach ($newProperty in $Properties.GetEnumerator()) {
                if ($currentProperties.ContainsKey($newProperty.Key)) {
                    if ($newProperty.Value -cne $currentProperties[$newProperty.Key]) {
                        $Rule | Set-ConfigurationItemProperty -Key $newProperty.Key -Value $newProperty.Value
                        $dirty = $true
                    }
                } else {
                    $Rule.Properties += [VideoOS.ConfigurationApi.ClientService.Property]@{ Key = $newProperty.Key; Value = $newProperty.Value.ToString() }
                    $dirty = $true
                }
            }
        }

        if ($dirty -and $PSCmdlet.ShouldProcess("Rule '$($Rule.DisplayName)'", 'Save changes')) {
            $null = $Rule | Set-ConfigurationItem
        }

        if ($PassThru) {
            $Rule
        }
    }
}

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

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

        [Parameter()]
        [string]
        $Description,

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

        [Parameter()]
        [ValidateTimeSpanRange('00:01:00', '365000.00:00:00')]
        [timespan]
        $Retention,

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

        [Parameter()]
        [switch]
        $ReduceFramerate,

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

    begin {
        Assert-VmsRequirementsMet
    }

    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])]
    [RequiresVmsConnection()]
    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')]
        [ValidateTimeSpanRange('00:01:00', '365000.00:00:00')]
        [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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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])]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.Storage]
        $Storage,

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

    begin {
        Assert-VmsRequirementsMet
    }

    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])]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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')]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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')]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }
    
    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()]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.Platform.ConfigurationItems.ConfigurationApiProperties]
        $Properties,

        [Parameter()]
        [switch]
        $UseDisplayNames
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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])]
    [RequiresVmsConnection($false)]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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])]
    [RequiresVmsConnection($false)]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [Alias('Bytes')]
        [byte[]]
        $Content
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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])]
    [RequiresVmsConnection($false)]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection($false)]
    param (
        [Parameter()]
        [string]
        $Path,
        [Parameter()]
        [string[]]
        $DeviceId,
        [Parameter()]
        [DateTime]
        $StartTime = [DateTime]::MinValue,
        [Parameter()]
        [DateTime]
        $EndTime = [DateTime]::MaxValue.AddHours(-1)
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection($false)]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]
        [ValidateNotNullOrEmpty()]
        $InputObject,
        [Parameter(Mandatory)]
        [string]
        [ValidateNotNullOrEmpty()]
        $Key
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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[]])]
    [RequiresVmsConnection()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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])]
    [RequiresVmsConnection($false)]
    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'
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection($false)]
    [RequiresElevation()]
    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-VmsRequirementsMet
        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()]
    [RequiresVmsConnection($false)]
    [RequiresElevation()]
    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')]
        [Parameter(ParameterSetName = 'DisableEncryption')]
        [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,

        [Parameter(ParameterSetName = 'Register')]
        [switch]
        $OverrideLocalManagementServer,

        # 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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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 ($MyInvocation.BoundParameters.ContainsKey('CertificateGroup') -and $CertificateGroup -notin $groups.Values) {
                    Write-Error "CertificateGroup value '$CertificateGroup' not found. Use the ListCertificateGroups switch to discover valid CertificateGroup values"
                    return
                }

                $enableArgs = @('/quiet', '/enableencryption', "/thumbprint=$Thumbprint")
                if ($MyInvocation.BoundParameters.ContainsKey('CertificateGroup')) {
                    $enableArgs += "/certificategroup=$CertificateGroup"
                }
                $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 ($MyInvocation.BoundParameters.ContainsKey('CertificateGroup') -and $CertificateGroup -notin $groups.Values) {
                    Write-Error "CertificateGroup value '$CertificateGroup' not found. Use the ListCertificateGroups switch to discover valid CertificateGroup values"
                    return
                }
                $disableArgs = @('/quiet', '/disableencryption')
                if ($MyInvocation.BoundParameters.ContainsKey('CertificateGroup')) {
                    $disableArgs += "/certificategroup=$CertificateGroup"
                }
                $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.ContainsKey('AuthAddress')) {
                    $registerArgs += '/managementserveraddress={0}' -f $AuthAddress.ToString()
                    if ($OverrideLocalManagementServer) {
                        $registerArgs += '/overridelocalmanagementserver'
                    }
                }
                $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])]
    [RequiresVmsConnection($false)]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection()]
    [RequiresInteractiveSession()]
    param(
        [Parameter()]
        [string]
        $Title = "Select Camera(s)",
        [Parameter()]
        [switch]
        $SingleSelect,
        [Parameter()]
        [switch]
        $AllowFolders,
        [Parameter()]
        [switch]
        $AllowServers,
        [Parameter()]
        [switch]
        $RemoveDuplicates,
        [Parameter()]
        [switch]
        $OutputAsItem
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection()]
    [RequiresInteractiveSession()]
    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
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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()]
    [RequiresVmsConnection($false)]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [VideoOS.ConfigurationApi.ClientService.ConfigurationItem]
        [ValidateNotNullOrEmpty()]
        $InputObject,
        [Parameter(Mandatory)]
        [string]
        [ValidateNotNullOrEmpty()]
        $Key,
        [Parameter(Mandatory)]
        [string]
        [ValidateNotNullOrEmpty()]
        $Value,
        [Parameter()]
        [switch]
        $PassThru
    )

    begin {
        Assert-VmsRequirementsMet
    }

    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
        }
    }
}
function Assert-VmsRequirementsMet {
    [CmdletBinding()]
    [RequiresVmsConnection($false)]
    param ()
    
    process {
        $frame = Get-PSCallStack | Select-Object -Skip 1 -First 1
        if ($frame.InvocationInfo.MyCommand.CommandType -ne 'Function') {
            return
        }
        foreach ($attribute in $frame.InvocationInfo.MyCommand.ScriptBlock.Attributes) {
            try {
                if (($requirement = $attribute -as [MilestonePSTools.IVmsRequirementValidator])) {
                    $requirement.Source = $frame.FunctionName
                    $requirement.Validate()
                }
            } catch {
                throw
            }
        }
    }
}
function Split-VmsConfigItemPath {
    [CmdletBinding(DefaultParameterSetName = 'Id')]
    [OutputType([string])]
    param (
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0, ParameterSetName = 'Id')]
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0, ParameterSetName = 'ItemType')]
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0, ParameterSetName = 'ParentItemType')]
        [AllowNull()]
        [AllowEmptyString()]
        [AllowEmptyCollection()]
        [string[]]
        $Path,

        [Parameter(ParameterSetName = 'Id')]
        [switch]
        $Id,

        [Parameter(ParameterSetName = 'ItemType')]
        [switch]
        $ItemType,

        [Parameter(ParameterSetName = 'ParentItemType')]
        [switch]
        $ParentItemType
    )
        
    process {
        if ($null -eq $Path) { $Path = '' }
        foreach ($record in $Path) {
            try {
                [videoos.platform.proxy.ConfigApi.ConfigurationItemPath]::new($record).($PSCmdlet.ParameterSetName)
            } catch {
                throw
            }
        }
    }
}
function Find-VmsVideoOSItem {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.Item])]
    [RequiresVmsConnection()]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)]
        [string[]]
        $SearchText,

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

        [Parameter()]
        [ValidateRange(1, [int]::MaxValue)]
        [int]
        $MaxSeconds = 30,

        [Parameter()]
        [KindNameTransformAttribute()]
        [guid]
        $Kind,

        [Parameter()]
        [VideoOS.Platform.FolderType]
        $FolderType
    )

    begin {
        Assert-VmsRequirementsMet
        $config = [VideoOS.Platform.Configuration]::Instance
    }

    process {
        foreach ($text in $SearchText) {
            $result = [VideoOS.Platform.SearchResult]::OK
            $items = $config.GetItemsBySearch($text, $MaxCount, $MaxSeconds, [ref]$result)

            foreach ($item in $items) {
                if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Kind') -and $item.FQID.Kind -ne $Kind) {
                    continue
                }
                if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('FolderType') -and $FolderType -ne $item.FQID.FolderType) {
                    continue
                }
                $item
            }

            if ($result -ne [VideoOS.Platform.SearchResult]::OK) {
                Write-Warning "Search result: $result"
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Find-VmsVideoOSItem -ParameterName Kind -ScriptBlock {
    $values = ([videoos.platform.kind].DeclaredMembers | Where-Object { $_.MemberType -eq 'Field' -and $_.FieldType -eq [guid] }).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsVideoOSItem {
    [CmdletBinding()]
    [OutputType([VideoOS.Platform.Item])]
    [RequiresVmsConnection()]
    param(
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'GetItemByFQID')]
        [VideoOS.Platform.FQID]
        $Fqid,

        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'GetItem')]
        [VideoOS.Platform.ServerId]
        $ServerId,

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

        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'GetItem')]
        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'GetItems')]
        [KindNameTransformAttribute()]
        [guid]
        $Kind,

        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'GetItems')]
        [VideoOS.Platform.ItemHierarchy]
        $ItemHierarchy = [VideoOS.Platform.ItemHierarchy]::SystemDefined,

        [Parameter(ParameterSetName = 'GetItems')]
        [VideoOS.Platform.FolderType]
        $FolderType
    )

    begin {
        Assert-VmsRequirementsMet
        $config = [VideoOS.Platform.Configuration]::Instance
    }

    process {
        try {
            switch ($PSCmdlet.ParameterSetName) {
                'GetItemByFQID' {
                    $config.GetItem($Fqid)
                }

                'GetItem' {
                    if ($ServerId) {
                        $config.GetItem($ServerId, $Id, $Kind)
                    } else {
                        $config.GetItem($Id, $Kind)
                    }
                }

                'GetItems' {
                    $checkKind = $false
                    $checkFolderType = $PSCmdlet.MyInvocation.BoundParameters.ContainsKey('FolderType')

                    $stack = [system.collections.generic.stack[VideoOS.Platform.Item]]::new()
                    if ($PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Kind')) {
                        $checkKind = $true
                        $config.GetItemsByKind($Kind, $ItemHierarchy) | Foreach-Object {
                            if ($null -ne $_) {
                                $stack.Push($_)
                            }
                        }
                    } else {
                        $config.GetItems($ItemHierarchy) | Foreach-Object {
                            if ($null -ne $_) {
                                $stack.Push($_)
                            }
                        }
                    }
                    while ($stack.Count -gt 0) {
                        $item = $stack.Pop()
                        if (-not $checkKind -or $item.FQID.Kind -eq $Kind) {
                            if (-not $checkFolderType -or $item.FQID.FolderType -eq $FolderType) {
                                $item
                            }
                        }
                        if ($item.HasChildren -ne 'No') {
                            $item.GetChildren() | ForEach-Object {
                                $stack.Push($_)
                            }
                        }
                    }
                }
                Default {
                    throw "ParameterSet '$_' not implemented."
                }
            }
        } catch {
            Write-Error -ErrorRecord $_
        }
    }
}

Register-ArgumentCompleter -CommandName Get-VmsVideoOSItem -ParameterName Kind -ScriptBlock {
    $values = ([videoos.platform.kind].DeclaredMembers | Where-Object { $_.MemberType -eq 'Field' -and $_.FieldType -eq [guid] }).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Get-VmsWebhook {
    [CmdletBinding(DefaultParameterSetName = 'Path')]
    [OutputType([MilestonePSTools.Webhook])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('23.1')]
    param (
        [Parameter(ValueFromPipelineByPropertyName, ParameterSetName = 'Name', Position = 0)]
        [SupportsWildcards()]
        [Alias('DisplayName')]
        [string]
        $Name,

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

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

        # Any unrecognized parameters and their values will be ignored when splatting a hashtable with keys that do not match a parameter name.
        [Parameter(ValueFromRemainingArguments, DontShow)]
        [object[]]
        $ExtraParams
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $folderPath = 'MIPKind[b9a5bc9c-e9a5-4a15-8453-ffa41f2815ac]/MIPItemFolder'

        if ($PSCmdlet.ParameterSetName -eq 'Path') {
            if ([string]::IsNullOrWhiteSpace($Path)) {
                Get-ConfigurationItem -Path $folderPath -ChildItems | ConvertTo-Webhook
            } else {
                Get-ConfigurationItem -Path $Path -ErrorAction Stop | ConvertTo-Webhook
            }
            return
        }

        $notFound = $true
        Get-ConfigurationItem -Path $folderPath -ChildItems -PipelineVariable webhook | ForEach-Object {
            switch ($PSCmdlet.ParameterSetName) {
                'Name' {
                    if ($webhook.DisplayName -like $Name) {
                        $notFound = $false
                        $webhook | ConvertTo-Webhook
                    }
                }

                'LiteralName' {
                    if ($webhook.DisplayName -eq $LiteralName) {
                        $notFound = $false
                        $webhook | ConvertTo-Webhook
                    }
                }
            }
        }
        if ($notFound -and ($PSCmdlet.ParameterSetName -eq 'LiteralName' -or -not [Management.Automation.WildcardPattern]::ContainsWildcardCharacters($Name))) {
            $Name = if ($PSCmdlet.ParameterSetName -eq 'Name') { $Name } else { $LiteralName }
            Write-Error -Message "Webhook with name matching '$Name' not found." -TargetObject $Name
        }
    }
}

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

Register-ArgumentCompleter -CommandName Get-VmsWebhook -ParameterName LiteralName -ScriptBlock {
    $values = (Get-VmsWebhook).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function New-VmsWebhook {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([MilestonePSTools.Webhook])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('23.1')]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, Position = 0)]
        [Alias('DisplayName')]
        [string]
        $Name,

        [Parameter(Mandatory, ValueFromPipelineByPropertyName)]
        [uri]
        $Address,

        [Parameter(ValueFromPipelineByPropertyName)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $Token,

        # Any unrecognized parameters and their values will be ignored when splatting a hashtable with keys that do not match a parameter name.
        [Parameter(ValueFromRemainingArguments, DontShow)]
        [object[]]
        $ExtraParams
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $folder = Get-ConfigurationItem -Path 'MIPKind[b9a5bc9c-e9a5-4a15-8453-ffa41f2815ac]/MIPItemFolder'
        $invokeInfo = $folder | Invoke-Method -MethodId AddMIPItem
        'ApiVersion', 'Address', 'Token' | ForEach-Object {
            $invokeInfo.Properties += [VideoOS.ConfigurationApi.ClientService.Property]@{
                Key         = $_
                DisplayName = $_
                ValueType   = 'String'
                IsSettable  = $true
            }
        }
        $action = 'Create webhook {0}' -f $Name
        if ($PSCmdlet.ShouldProcess((Get-VmsSite).Name, $action)) {
            $invokeInfo | Set-ConfigurationItemProperty -Key Name -Value $Name
            $invokeInfo | Set-ConfigurationItemProperty -Key Address -Value $Address
            $invokeInfo | Set-ConfigurationItemProperty -Key ApiVersion -Value 'v1.0'
            if (-not [string]::IsNullOrWhiteSpace($Token)) {
                $invokeInfo | Set-ConfigurationItemProperty -Key Token -Value $Token
            }
            $invokeInfo | Invoke-Method -MethodId AddMIPItem | Get-ConfigurationItem | ConvertTo-Webhook
        }
    }
}
function Remove-VmsWebhook {
    [CmdletBinding(DefaultParameterSetName = 'Path', SupportsShouldProcess)]
    [RequiresVmsVersion('23.1')]
    [RequiresVmsConnection()]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Name', Position = 0)]
        [Alias('DisplayName')]
        [string]
        $Name,

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

        # Any unrecognized parameters and their values will be ignored when splatting a hashtable with keys that do not match a parameter name.
        [Parameter(ValueFromRemainingArguments, DontShow)]
        [object[]]
        $ExtraParams
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $folder = Get-ConfigurationItem -Path 'MIPKind[b9a5bc9c-e9a5-4a15-8453-ffa41f2815ac]/MIPItemFolder'
        $invokeInfo = $folder | Invoke-Method -MethodId RemoveMIPItem
        if ([string]::IsNullOrWhiteSpace($Path)) {
            $valueTypeInfo = $invokeInfo.Properties[0].ValueTypeInfos | Where-Object Name -EQ $Name
            if ($null -eq $valueTypeInfo) {
                Write-Error -Message "Webhook with name '$Name' not found." -TargetObject $Name
                return
            }
            if ($valueTypeInfo.Count -gt 1) {
                Write-Error -Message "Multiple webhooks found with name '$Name'. To remove a specific webhook, use 'Get-VmsWebhook -Name ''$Name'' | Remove-VmsWebhook'." -TargetObject $Name
                return
            }
            $Path = $valueTypeInfo.Value
        } else {
            $Name = ($invokeInfo.Properties[0].ValueTypeInfos | Where-Object Value -EQ $Path).Name
        }
        
        $action = 'Remove webhook {0}' -f $Name
        if ($PSCmdlet.ShouldProcess((Get-VmsSite).Name, $action)) {
            $invokeInfo | Set-ConfigurationItemProperty -Key ItemSelection -Value $Path
            $null = $invokeInfo | Invoke-Method -MethodId RemoveMIPItem
        }
    }
}

Register-ArgumentCompleter -CommandName Remove-VmsWebhook -ParameterName Name -ScriptBlock {
    $values = (Get-VmsWebhook).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
function Set-VmsWebhook {
    [CmdletBinding(DefaultParameterSetName = 'Path', SupportsShouldProcess)]
    [OutputType([MilestonePSTools.Webhook])]
    [RequiresVmsConnection()]
    [RequiresVmsVersion('23.1')]
    param (
        [Parameter(Mandatory, ValueFromPipelineByPropertyName, ParameterSetName = 'Name', Position = 0)]
        [Alias('DisplayName')]
        [string]
        $Name,

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

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

        [Parameter(ValueFromPipelineByPropertyName)]
        [uri]
        $Address,

        [Parameter(ValueFromPipelineByPropertyName)]
        [AllowEmptyString()]
        [AllowNull()]
        [string]
        $Token,

        [Parameter()]
        [switch]
        $PassThru,

        # Any unrecognized parameters and their values will be ignored when splatting a hashtable with keys that do not match a parameter name.
        [Parameter(ValueFromRemainingArguments, DontShow)]
        [object[]]
        $ExtraParams
    )

    begin {
        Assert-VmsRequirementsMet
    }

    process {
        $folder = Get-ConfigurationItem -Path 'MIPKind[b9a5bc9c-e9a5-4a15-8453-ffa41f2815ac]/MIPItemFolder'
        $invokeInfo = $folder | Invoke-Method -MethodId RemoveMIPItem
        if ([string]::IsNullOrWhiteSpace($Path)) {
            $valueTypeInfo = $invokeInfo.Properties[0].ValueTypeInfos | Where-Object Name -EQ $Name
            if ($null -eq $valueTypeInfo) {
                Write-Error -Message "Webhook with name '$Name' not found." -TargetObject $Name
                return
            }
            if ($valueTypeInfo.Count -gt 1) {
                Write-Error -Message "Multiple webhooks found with name '$Name'. Use 'Get-VmsWebhook' to find the one to update, and pipe it to Set-VmsWebhook instead to use the Path parameter rather than the Name." -TargetObject $Name
                return
            }
            $Path = $valueTypeInfo.Value
        }

        $webhook = Get-ConfigurationItem -Path $Path -ErrorAction Stop
        $dirty = $false
        'NewName', 'Address', 'Token' | ForEach-Object {
            if (-not $PSCmdlet.MyInvocation.BoundParameters.ContainsKey($_)) {
                return
            }
            $key = $_ -replace 'New', ''
            $property = $webhook.Properties | Where-Object Key -EQ $key
            if ($null -eq $property) {
                $dirty = $false
                throw "Property with key '$key' not found."
            }
            $currentValue = $property.Value
            $newValue = (Get-Variable -Name $_).Value
            if ($currentValue -cne $newValue) {
                Write-Verbose "Changing $key from '$currentValue' to '$newValue' on webhook '$($webhook.DisplayName)'"
                $dirty = $true
                $property.Value = $newValue
            }
        }

        $action = 'Update webhook {0}' -f $webhook.DisplayName
        if ($dirty -and $PSCmdlet.ShouldProcess((Get-VmsSite).Name, $action)) {
            $null = $webhook | Set-ConfigurationItem -ErrorAction Stop
        }
        if ($PassThru) {
            $webhook | Get-VmsWebhook
        }
    }
}

Register-ArgumentCompleter -CommandName Set-VmsWebhook -ParameterName Name -ScriptBlock {
    $values = (Get-VmsWebhook).Name | Sort-Object
    Complete-SimpleArgument -Arguments $args -ValueSet $values
}
<#
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 :)
#>


#region Argument Completers
# The default place for argument completers is within the same .PS1 as the function
# but argument completers for C# cmdlets can be placed here if needed.

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

Register-ArgumentCompleter -CommandName Start-Export -ParameterName Codec -ScriptBlock {
    $location = [environment]::CurrentDirectory
    try {
        Push-Location -Path $MipSdkPath
        [environment]::CurrentDirectory = $MipSdkPath
        $exporter = [VideoOS.Platform.Data.AVIExporter]::new()
        $values = $exporter.CodecList | Sort-Object
        Complete-SimpleArgument -Arguments $args -ValueSet $values
    } finally {
        [environment]::CurrentDirectory = $location
        Pop-Location
        if ($exporter) {
            $exporter.Close()
        }
    }
}


#endregion

# Enable the use of any TLS protocol version greater than or equal to TLS 1.2
$protocol = [Net.SecurityProtocolType]::SystemDefault
[enum]::GetNames([Net.SecurityProtocolType]) | Where-Object {
    # Match any TLS version greater than 1.1
            ($_ -match 'Tls(\d)(\d+)?') -and ([version]("$($Matches[1]).$([int]$Matches[2])")) -gt 1.1
} | ForEach-Object { $protocol = $protocol -bor [Net.SecurityProtocolType]::$_ }
[Net.ServicePointManager]::SecurityProtocol = $protocol

$script:Deprecations = Import-PowerShellDataFile -Path "$PSScriptRoot\deprecations.psd1"
$script:Messages = @{}
Import-LocalizedData -BindingVariable 'script:Messages' -FileName 'messages'
Export-ModuleMember -Cmdlet * -Alias * -Function *


# SIG # Begin signature block
# MIIulgYJKoZIhvcNAQcCoIIuhzCCLoMCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAspXSEnSijvBNl
# EboZeUrtFugmXkXUG8bi8TNL/POOhaCCE24wggVyMIIDWqADAgECAhB2U/6sdUZI
# k/Xl10pIOk74MA0GCSqGSIb3DQEBDAUAMFMxCzAJBgNVBAYTAkJFMRkwFwYDVQQK
# ExBHbG9iYWxTaWduIG52LXNhMSkwJwYDVQQDEyBHbG9iYWxTaWduIENvZGUgU2ln
# bmluZyBSb290IFI0NTAeFw0yMDAzMTgwMDAwMDBaFw00NTAzMTgwMDAwMDBaMFMx
# CzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMSkwJwYDVQQD
# EyBHbG9iYWxTaWduIENvZGUgU2lnbmluZyBSb290IFI0NTCCAiIwDQYJKoZIhvcN
# AQEBBQADggIPADCCAgoCggIBALYtxTDdeuirkD0DcrA6S5kWYbLl/6VnHTcc5X7s
# k4OqhPWjQ5uYRYq4Y1ddmwCIBCXp+GiSS4LYS8lKA/Oof2qPimEnvaFE0P31PyLC
# o0+RjbMFsiiCkV37WYgFC5cGwpj4LKczJO5QOkHM8KCwex1N0qhYOJbp3/kbkbuL
# ECzSx0Mdogl0oYCve+YzCgxZa4689Ktal3t/rlX7hPCA/oRM1+K6vcR1oW+9YRB0
# RLKYB+J0q/9o3GwmPukf5eAEh60w0wyNA3xVuBZwXCR4ICXrZ2eIq7pONJhrcBHe
# OMrUvqHAnOHfHgIB2DvhZ0OEts/8dLcvhKO/ugk3PWdssUVcGWGrQYP1rB3rdw1G
# R3POv72Vle2dK4gQ/vpY6KdX4bPPqFrpByWbEsSegHI9k9yMlN87ROYmgPzSwwPw
# jAzSRdYu54+YnuYE7kJuZ35CFnFi5wT5YMZkobacgSFOK8ZtaJSGxpl0c2cxepHy
# 1Ix5bnymu35Gb03FhRIrz5oiRAiohTfOB2FXBhcSJMDEMXOhmDVXR34QOkXZLaRR
# kJipoAc3xGUaqhxrFnf3p5fsPxkwmW8x++pAsufSxPrJ0PBQdnRZ+o1tFzK++Ol+
# A/Tnh3Wa1EqRLIUDEwIrQoDyiWo2z8hMoM6e+MuNrRan097VmxinxpI68YJj8S4O
# JGTfAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0G
# A1UdDgQWBBQfAL9GgAr8eDm3pbRD2VZQu86WOzANBgkqhkiG9w0BAQwFAAOCAgEA
# Xiu6dJc0RF92SChAhJPuAW7pobPWgCXme+S8CZE9D/x2rdfUMCC7j2DQkdYc8pzv
# eBorlDICwSSWUlIC0PPR/PKbOW6Z4R+OQ0F9mh5byV2ahPwm5ofzdHImraQb2T07
# alKgPAkeLx57szO0Rcf3rLGvk2Ctdq64shV464Nq6//bRqsk5e4C+pAfWcAvXda3
# XaRcELdyU/hBTsz6eBolSsr+hWJDYcO0N6qB0vTWOg+9jVl+MEfeK2vnIVAzX9Rn
# m9S4Z588J5kD/4VDjnMSyiDN6GHVsWbcF9Y5bQ/bzyM3oYKJThxrP9agzaoHnT5C
# JqrXDO76R78aUn7RdYHTyYpiF21PiKAhoCY+r23ZYjAf6Zgorm6N1Y5McmaTgI0q
# 41XHYGeQQlZcIlEPs9xOOe5N3dkdeBBUO27Ql28DtR6yI3PGErKaZND8lYUkqP/f
# obDckUCu3wkzq7ndkrfxzJF0O2nrZ5cbkL/nx6BvcbtXv7ePWu16QGoWzYCELS/h
# AtQklEOzFfwMKxv9cW/8y7x1Fzpeg9LJsy8b1ZyNf1T+fn7kVqOHp53hWVKUQY9t
# W76GlZr/GnbdQNJRSnC0HzNjI3c/7CceWeQIh+00gkoPP/6gHcH1Z3NFhnj0qinp
# J4fGGdvGExTDOUmHTaCX4GUT9Z13Vunas1jHOvLAzYIwggbmMIIEzqADAgECAhB3
# vQ4DobcI+FSrBnIQ2QRHMA0GCSqGSIb3DQEBCwUAMFMxCzAJBgNVBAYTAkJFMRkw
# FwYDVQQKExBHbG9iYWxTaWduIG52LXNhMSkwJwYDVQQDEyBHbG9iYWxTaWduIENv
# ZGUgU2lnbmluZyBSb290IFI0NTAeFw0yMDA3MjgwMDAwMDBaFw0zMDA3MjgwMDAw
# MDBaMFkxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBHbG9iYWxTaWduIG52LXNhMS8w
# LQYDVQQDEyZHbG9iYWxTaWduIEdDQyBSNDUgQ29kZVNpZ25pbmcgQ0EgMjAyMDCC
# AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANZCTfnjT8Yj9GwdgaYw90g9
# z9DljeUgIpYHRDVdBs8PHXBg5iZU+lMjYAKoXwIC947Jbj2peAW9jvVPGSSZfM8R
# Fpsfe2vSo3toZXer2LEsP9NyBjJcW6xQZywlTVYGNvzBYkx9fYYWlZpdVLpQ0LB/
# okQZ6dZubD4Twp8R1F80W1FoMWMK+FvQ3rpZXzGviWg4QD4I6FNnTmO2IY7v3Y2F
# QVWeHLw33JWgxHGnHxulSW4KIFl+iaNYFZcAJWnf3sJqUGVOU/troZ8YHooOX1Re
# veBbz/IMBNLeCKEQJvey83ouwo6WwT/Opdr0WSiMN2WhMZYLjqR2dxVJhGaCJedD
# CndSsZlRQv+hst2c0twY2cGGqUAdQZdihryo/6LHYxcG/WZ6NpQBIIl4H5D0e6lS
# TmpPVAYqgK+ex1BC+mUK4wH0sW6sDqjjgRmoOMieAyiGpHSnR5V+cloqexVqHMRp
# 5rC+QBmZy9J9VU4inBDgoVvDsy56i8Te8UsfjCh5MEV/bBO2PSz/LUqKKuwoDy3K
# 1JyYikptWjYsL9+6y+JBSgh3GIitNWGUEvOkcuvuNp6nUSeRPPeiGsz8h+WX4VGH
# aekizIPAtw9FbAfhQ0/UjErOz2OxtaQQevkNDCiwazT+IWgnb+z4+iaEW3VCzYkm
# eVmda6tjcWKQJQ0IIPH/AgMBAAGjggGuMIIBqjAOBgNVHQ8BAf8EBAMCAYYwEwYD
# VR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU
# 2rONwCSQo2t30wygWd0hZ2R2C3gwHwYDVR0jBBgwFoAUHwC/RoAK/Hg5t6W0Q9lW
# ULvOljswgZMGCCsGAQUFBwEBBIGGMIGDMDkGCCsGAQUFBzABhi1odHRwOi8vb2Nz
# cC5nbG9iYWxzaWduLmNvbS9jb2Rlc2lnbmluZ3Jvb3RyNDUwRgYIKwYBBQUHMAKG
# Omh0dHA6Ly9zZWN1cmUuZ2xvYmFsc2lnbi5jb20vY2FjZXJ0L2NvZGVzaWduaW5n
# cm9vdHI0NS5jcnQwQQYDVR0fBDowODA2oDSgMoYwaHR0cDovL2NybC5nbG9iYWxz
# aWduLmNvbS9jb2Rlc2lnbmluZ3Jvb3RyNDUuY3JsMFYGA1UdIARPME0wQQYJKwYB
# BAGgMgEyMDQwMgYIKwYBBQUHAgEWJmh0dHBzOi8vd3d3Lmdsb2JhbHNpZ24uY29t
# L3JlcG9zaXRvcnkvMAgGBmeBDAEEATANBgkqhkiG9w0BAQsFAAOCAgEACIhyJsav
# +qxfBsCqjJDa0LLAopf/bhMyFlT9PvQwEZ+PmPmbUt3yohbu2XiVppp8YbgEtfjr
# y/RhETP2ZSW3EUKL2Glux/+VtIFDqX6uv4LWTcwRo4NxahBeGQWn52x/VvSoXMNO
# Ca1Za7j5fqUuuPzeDsKg+7AE1BMbxyepuaotMTvPRkyd60zsvC6c8YejfzhpX0FA
# Z/ZTfepB7449+6nUEThG3zzr9s0ivRPN8OHm5TOgvjzkeNUbzCDyMHOwIhz2hNab
# XAAC4ShSS/8SS0Dq7rAaBgaehObn8NuERvtz2StCtslXNMcWwKbrIbmqDvf+28rr
# vBfLuGfr4z5P26mUhmRVyQkKwNkEcUoRS1pkw7x4eK1MRyZlB5nVzTZgoTNTs/Z7
# KtWJQDxxpav4mVn945uSS90FvQsMeAYrz1PYvRKaWyeGhT+RvuB4gHNU36cdZytq
# tq5NiYAkCFJwUPMB/0SuL5rg4UkI4eFb1zjRngqKnZQnm8qjudviNmrjb7lYYuA2
# eDYB+sGniXomU6Ncu9Ky64rLYwgv/h7zViniNZvY/+mlvW1LWSyJLC9Su7UpkNpD
# R7xy3bzZv4DB3LCrtEsdWDY3ZOub4YUXmimi/eYI0pL/oPh84emn0TCOXyZQK8ei
# 4pd3iu/YTT4m65lAYPM8Zwy2CHIpNVOBNNwwggcKMIIE8qADAgECAgxjM0+VRu6w
# 7+lFi6kwDQYJKoZIhvcNAQELBQAwWTELMAkGA1UEBhMCQkUxGTAXBgNVBAoTEEds
# b2JhbFNpZ24gbnYtc2ExLzAtBgNVBAMTJkdsb2JhbFNpZ24gR0NDIFI0NSBDb2Rl
# U2lnbmluZyBDQSAyMDIwMB4XDTI0MDEyNjE1MDcwMVoXDTI1MDEyNjE1MDcwMVow
# eDELMAkGA1UEBhMCVVMxDzANBgNVBAgTBk9yZWdvbjEUMBIGA1UEBxMLTGFrZSBP
# c3dlZ28xIDAeBgNVBAoTF01pbGVzdG9uZSBTeXN0ZW1zLCBJbmMuMSAwHgYDVQQD
# ExdNaWxlc3RvbmUgU3lzdGVtcywgSW5jLjCCAiIwDQYJKoZIhvcNAQEBBQADggIP
# ADCCAgoCggIBANuPJQeA9USbN3zbAcb5W7/1Pc6RnmFcaei4VgjAoZ90TW2f69uY
# ELwNlwnQAKlTfr9khIMRUVVNIjVwjGeUpDi5u20RaYAwdXs0t3p9BnxvgC7kqLXC
# RP+iKvd1oiFy/WfHwSoHkgr3ZfXW22cfKMRlEBqkOnMnrTvpHNsx0BgFt4Uwt+uY
# 5uXoAw0sw28OkBJFBV8BBc+bRqQ+7cwtzmyA7HpNwcC/gFcotOT7p9APL6V0K01t
# ZO5JTKephmN1ycQz/SZK6Fbl8Myy0Gla4ZON9gThUVYmNvLEouwoVj/7CCAA0Oni
# n3pI9NHDHOu7lJz/N5YGxKQK95WCFrOmcRq5eEJHRN6XGRwzCTMOnOQ9J8MA9cfx
# WfzmbOU4jwscTGrXP9ffv3IvdcOkE63cFLBWPja07/qnrCC57mAZloGqCJYS1hvv
# cefIu1AhoAqn1xXnwKLHqW4dAOSorMXjGo6w+0AyV2RgRXkmi+zgCaOy9Xe9XG44
# 9vlJstGU3Mjvr2WrhFFE05hyE7hPPI7EcAm+JXCVBn7yzLIIqsD4GgPddMYg9omY
# QpdKe5eMiJL1jErvxr7aazgcqcgmuIabReqocH36ANVMfoBo140ePgbAfMJ5gslE
# PEoo7ArovmXoqs2t62o8SFx/071+dTGYZshlKTpYGZZ9ydpIuuMZJSJ/AgMBAAGj
# ggGxMIIBrTAOBgNVHQ8BAf8EBAMCB4AwgZsGCCsGAQUFBwEBBIGOMIGLMEoGCCsG
# AQUFBzAChj5odHRwOi8vc2VjdXJlLmdsb2JhbHNpZ24uY29tL2NhY2VydC9nc2dj
# Y3I0NWNvZGVzaWduY2EyMDIwLmNydDA9BggrBgEFBQcwAYYxaHR0cDovL29jc3Au
# Z2xvYmFsc2lnbi5jb20vZ3NnY2NyNDVjb2Rlc2lnbmNhMjAyMDBWBgNVHSAETzBN
# MEEGCSsGAQQBoDIBMjA0MDIGCCsGAQUFBwIBFiZodHRwczovL3d3dy5nbG9iYWxz
# aWduLmNvbS9yZXBvc2l0b3J5LzAIBgZngQwBBAEwCQYDVR0TBAIwADBFBgNVHR8E
# PjA8MDqgOKA2hjRodHRwOi8vY3JsLmdsb2JhbHNpZ24uY29tL2dzZ2NjcjQ1Y29k
# ZXNpZ25jYTIwMjAuY3JsMBMGA1UdJQQMMAoGCCsGAQUFBwMDMB8GA1UdIwQYMBaA
# FNqzjcAkkKNrd9MMoFndIWdkdgt4MB0GA1UdDgQWBBT3z5hC3BNSWFzfQByqFmRK
# y9AYkTANBgkqhkiG9w0BAQsFAAOCAgEAFyXnmIB6a+IH36icvO/LaNa+Hbz4H6JH
# st8Lpq9Xefzt8fVMOXMPC163mXHHQjyEXl7qqz++mQgXq9j/gg89gIoqP02QE8A2
# 3MP4Zqs94lTJfQd1vGN0wts5g87foU366iCxPPGy3d91UmbARdpLIP5MfCjAZqEB
# Sm1phhOXxFF55QnGfn0rX4MBY63lPm2acgUU+A+36sOyZh1xS9EczsmveTmpx3i6
# JlGjdmKfxNh91vqHnuHgiMRXradVbkUPih4SLdSw94g3Nj9qYLKk2/tHV3fQfwml
# gPhW6MUgdUts/UWIwFKhLaVb+BID+asbFVoaOQw1a0Whk/Nd6+CVH62dExBA/ZxV
# Y6MBfZQk6tU6uWpReEQHJuYqqBr9YOdBYr070SJbyjZWFUdGzGKLXbpaeSNNxDUf
# 7oaHT2IyHIfrhEykk1mLHoGTZlnRlG3S/v+N3t1FnUhD0Oss3QjAxQKg3byHCjGe
# vbfajoXMFjLZFKb1cUD7tDctmg4/ZwKar5xdrpgKsXFC3PrCrSbq5YqIA9qQ/M6y
# 8wbKVQ/AEdHmaSGOs+Xqq6yQ8+h9jW/QbdhMhj1WWMhEV0wnAy7i7Jx6kuramAvS
# 2I8ypu5QlAx3eiAUG4gYOBPXLlGDBz5J1I+Ulggintgy4rXgbNdBwPGTPWY6QlxM
# 8XNwFnFuDh8xghp+MIIaegIBATBpMFkxCzAJBgNVBAYTAkJFMRkwFwYDVQQKExBH
# bG9iYWxTaWduIG52LXNhMS8wLQYDVQQDEyZHbG9iYWxTaWduIEdDQyBSNDUgQ29k
# ZVNpZ25pbmcgQ0EgMjAyMAIMYzNPlUbusO/pRYupMA0GCWCGSAFlAwQCAQUAoIGk
# MBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgor
# BgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCCH+1yYMphOnNSux/gXLDKhFCC8EFl/
# 5fLJZ5hVrCvnUTA4BgorBgEEAYI3AgEMMSowKKACgAChIoAgaHR0cHM6Ly93d3cu
# bWlsZXN0b25lcHN0b29scy5jb20wDQYJKoZIhvcNAQEBBQAEggIAXb8mBKPxJ0KL
# 18vyHQ1VfXb4yxIu+q1DZod6qot3Kkjt1d7kISNGQUIfK80mmXBWUqYxYIXWFykw
# oW8NvUi1P9++WzsPPbunzB6Vmw+0WiawwYqs+CwKvjrQFbmKqHzddVJki9A6bQco
# m9GmGKhw9p+x2iTqw1sjtc5ypeagSg6Rep1Ac7WAAKDwfPCK4HEyPm4Q4GuAJ0yq
# rjFIc4PjMx5XMZ35g4CRvf++gusGsXwY2k93Mj+l3Ih9ZefDlu/qxgDVMWO7NIm/
# oQPqZCwCytUYKjBzSpDykeftMAl7heqxcP1zaS079AiY8Hj11aHCcHSlDPpalH5W
# BwLhyxNPUMv/hi+tqAwvUMY0lQg65svVr0AI0acRjgft1EzNNrmkmLJr66Uv1dHY
# NtM+76q+4JojQdzwUnBB23MOcmVi7MFWVyc49Cd7Wyv0X8eUjJY7PrsBv6UXi9hN
# sp6dsRu8AMMBGLHqJ59MhEcAIvMZT+71/37YQlB6oP+BJwcjHtfICN1j0v7NhBpI
# sI3dXK6Yx1BLwg5aIpTwYNUXX6Q9siysrDO0OCyD99ho5YPfeHWSAb7QbdFUEwbW
# zgNbs+VkhXqMgxS6NuwN454ERLXtOv5yTIXsjnTiXNNoAKi0SPBcKcQRaUYdOOzP
# JTBdqTY4x6ksRuKCdOZtznhvZ5Q7a72hghc/MIIXOwYKKwYBBAGCNwMDATGCFysw
# ghcnBgkqhkiG9w0BBwKgghcYMIIXFAIBAzEPMA0GCWCGSAFlAwQCAQUAMHcGCyqG
# SIb3DQEJEAEEoGgEZjBkAgEBBglghkgBhv1sBwEwMTANBglghkgBZQMEAgEFAAQg
# waHnZa+Eg4y3TnpSjXwSr+Bs2KxoMKGXOpUKikbnUo4CEHcy5sR38miNWyuXsp28
# y2UYDzIwMjQwNTI5MDExODAzWqCCEwkwggbCMIIEqqADAgECAhAFRK/zlJ0IOaa/
# 2z9f5WEWMA0GCSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5E
# aWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0
# MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwHhcNMjMwNzE0MDAwMDAwWhcNMzQx
# MDEzMjM1OTU5WjBIMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu
# Yy4xIDAeBgNVBAMTF0RpZ2lDZXJ0IFRpbWVzdGFtcCAyMDIzMIICIjANBgkqhkiG
# 9w0BAQEFAAOCAg8AMIICCgKCAgEAo1NFhx2DjlusPlSzI+DPn9fl0uddoQ4J3C9I
# o5d6OyqcZ9xiFVjBqZMRp82qsmrdECmKHmJjadNYnDVxvzqX65RQjxwg6seaOy+W
# ZuNp52n+W8PWKyAcwZeUtKVQgfLPywemMGjKg0La/H8JJJSkghraarrYO8pd3hkY
# hftF6g1hbJ3+cV7EBpo88MUueQ8bZlLjyNY+X9pD04T10Mf2SC1eRXWWdf7dEKEb
# g8G45lKVtUfXeCk5a+B4WZfjRCtK1ZXO7wgX6oJkTf8j48qG7rSkIWRw69XloNpj
# sy7pBe6q9iT1HbybHLK3X9/w7nZ9MZllR1WdSiQvrCuXvp/k/XtzPjLuUjT71Lvr
# 1KAsNJvj3m5kGQc3AZEPHLVRzapMZoOIaGK7vEEbeBlt5NkP4FhB+9ixLOFRr7St
# FQYU6mIIE9NpHnxkTZ0P387RXoyqq1AVybPKvNfEO2hEo6U7Qv1zfe7dCv95NBB+
# plwKWEwAPoVpdceDZNZ1zY8SdlalJPrXxGshuugfNJgvOuprAbD3+yqG7HtSOKmY
# CaFxsmxxrz64b5bV4RAT/mFHCoz+8LbH1cfebCTwv0KCyqBxPZySkwS0aXAnDU+3
# tTbRyV8IpHCj7ArxES5k4MsiK8rxKBMhSVF+BmbTO77665E42FEHypS34lCh8zrT
# ioPLQHsCAwEAAaOCAYswggGHMA4GA1UdDwEB/wQEAwIHgDAMBgNVHRMBAf8EAjAA
# MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMIMCAGA1UdIAQZMBcwCAYGZ4EMAQQCMAsG
# CWCGSAGG/WwHATAfBgNVHSMEGDAWgBS6FtltTYUvcyl2mi91jGogj57IbzAdBgNV
# HQ4EFgQUpbbvE+fvzdBkodVWqWUxo97V40kwWgYDVR0fBFMwUTBPoE2gS4ZJaHR0
# cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5NlNI
# QTI1NlRpbWVTdGFtcGluZ0NBLmNybDCBkAYIKwYBBQUHAQEEgYMwgYAwJAYIKwYB
# BQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBYBggrBgEFBQcwAoZMaHR0
# cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5
# NlNIQTI1NlRpbWVTdGFtcGluZ0NBLmNydDANBgkqhkiG9w0BAQsFAAOCAgEAgRrW
# 3qCptZgXvHCNT4o8aJzYJf/LLOTN6l0ikuyMIgKpuM+AqNnn48XtJoKKcS8Y3U62
# 3mzX4WCcK+3tPUiOuGu6fF29wmE3aEl3o+uQqhLXJ4Xzjh6S2sJAOJ9dyKAuJXgl
# nSoFeoQpmLZXeY/bJlYrsPOnvTcM2Jh2T1a5UsK2nTipgedtQVyMadG5K8TGe8+c
# +njikxp2oml101DkRBK+IA2eqUTQ+OVJdwhaIcW0z5iVGlS6ubzBaRm6zxbygzc0
# brBBJt3eWpdPM43UjXd9dUWhpVgmagNF3tlQtVCMr1a9TMXhRsUo063nQwBw3syY
# nhmJA+rUkTfvTVLzyWAhxFZH7doRS4wyw4jmWOK22z75X7BC1o/jF5HRqsBV44a/
# rCcsQdCaM0qoNtS5cpZ+l3k4SF/Kwtw9Mt911jZnWon49qfH5U81PAC9vpwqbHkB
# 3NpE5jreODsHXjlY9HxzMVWggBHLFAx+rrz+pOt5Zapo1iLKO+uagjVXKBbLafIy
# mrLS2Dq4sUaGa7oX/cR3bBVsrquvczroSUa31X/MtjjA2Owc9bahuEMs305MfR5o
# cMB3CtQC4Fxguyj/OOVSWtasFyIjTvTs0xf7UGv/B3cfcZdEQcm4RtNsMnxYL2dH
# ZeUbc7aZ+WssBkbvQR7w8F/g29mtkIBEr4AQQYowggauMIIElqADAgECAhAHNje3
# JFR82Ees/ShmKl5bMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYD
# VQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAf
# BgNVBAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0yMjAzMjMwMDAwMDBa
# Fw0zNzAzMjIyMzU5NTlaMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2Vy
# dCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2IFNI
# QTI1NiBUaW1lU3RhbXBpbmcgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK
# AoICAQDGhjUGSbPBPXJJUVXHJQPE8pE3qZdRodbSg9GeTKJtoLDMg/la9hGhRBVC
# X6SI82j6ffOciQt/nR+eDzMfUBMLJnOWbfhXqAJ9/UO0hNoR8XOxs+4rgISKIhjf
# 69o9xBd/qxkrPkLcZ47qUT3w1lbU5ygt69OxtXXnHwZljZQp09nsad/ZkIdGAHvb
# REGJ3HxqV3rwN3mfXazL6IRktFLydkf3YYMZ3V+0VAshaG43IbtArF+y3kp9zvU5
# EmfvDqVjbOSmxR3NNg1c1eYbqMFkdECnwHLFuk4fsbVYTXn+149zk6wsOeKlSNbw
# sDETqVcplicu9Yemj052FVUmcJgmf6AaRyBD40NjgHt1biclkJg6OBGz9vae5jtb
# 7IHeIhTZgirHkr+g3uM+onP65x9abJTyUpURK1h0QCirc0PO30qhHGs4xSnzyqqW
# c0Jon7ZGs506o9UD4L/wojzKQtwYSH8UNM/STKvvmz3+DrhkKvp1KCRB7UK/BZxm
# SVJQ9FHzNklNiyDSLFc1eSuo80VgvCONWPfcYd6T/jnA+bIwpUzX6ZhKWD7TA4j+
# s4/TXkt2ElGTyYwMO1uKIqjBJgj5FBASA31fI7tk42PgpuE+9sJ0sj8eCXbsq11G
# deJgo1gJASgADoRU7s7pXcheMBK9Rp6103a50g5rmQzSM7TNsQIDAQABo4IBXTCC
# AVkwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUuhbZbU2FL3MpdpovdYxq
# II+eyG8wHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/
# BAQDAgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggr
# BgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVo
# dHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0
# LmNydDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20v
# RGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjAL
# BglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAH1ZjsCTtm+YqUQiAX5m1tgh
# QuGwGC4QTRPPMFPOvxj7x1Bd4ksp+3CKDaopafxpwc8dB+k+YMjYC+VcW9dth/qE
# ICU0MWfNthKWb8RQTGIdDAiCqBa9qVbPFXONASIlzpVpP0d3+3J0FNf/q0+KLHqr
# hc1DX+1gtqpPkWaeLJ7giqzl/Yy8ZCaHbJK9nXzQcAp876i8dU+6WvepELJd6f8o
# VInw1YpxdmXazPByoyP6wCeCRK6ZJxurJB4mwbfeKuv2nrF5mYGjVoarCkXJ38SN
# oOeY+/umnXKvxMfBwWpx2cYTgAnEtp/Nh4cku0+jSbl3ZpHxcpzpSwJSpzd+k1Os
# Ox0ISQ+UzTl63f8lY5knLD0/a6fxZsNBzU+2QJshIUDQtxMkzdwdeDrknq3lNHGS
# 1yZr5Dhzq6YBT70/O3itTK37xJV77QpfMzmHQXh6OOmc4d0j/R0o08f56PGYX/sr
# 2H7yRp11LB4nLCbbbxV7HhmLNriT1ObyF5lZynDwN7+YAN8gFk8n+2BnFqFmut1V
# wDophrCYoCvtlUG3OtUVmDG0YgkPCr2B2RP+v6TR81fZvAT6gt4y3wSJ8ADNXcL5
# 0CN/AAvkdgIm2fBldkKmKYcJRyvmfxqkhQ/8mJb2VVQrH4D6wPIOK+XW+6kvRBVK
# 5xMOHds3OBqhK/bt1nz8MIIFjTCCBHWgAwIBAgIQDpsYjvnQLefv21DiCEAYWjAN
# BgkqhkiG9w0BAQwFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQg
# SW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2Vy
# dCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMjIwODAxMDAwMDAwWhcNMzExMTA5MjM1
# OTU5WjBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYD
# VQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVk
# IFJvb3QgRzQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN67
# 5F1KPDAiMGkz7MKnJS7JIT3yithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaX
# bR2rsnnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQ
# Lt+C8weE5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82s
# NEBfsXpm7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4Da
# tpGYQJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwh
# TNS8rhsDdV14Ztk6MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98Fp
# iHaYdj1ZXUJ2h4mXaXpI8OCiEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppE
# GSt+wJS00mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+
# 9oCw++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56
# rF+NP8m800ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8
# oR7FwI+isX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo4IBOjCCATYwDwYDVR0TAQH/
# BAUwAwEB/zAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wHwYDVR0jBBgw
# FoAUReuir/SSy4IxLVGLp6chnfNtyA8wDgYDVR0PAQH/BAQDAgGGMHkGCCsGAQUF
# BwEBBG0wazAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEMG
# CCsGAQUFBzAChjdodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRB
# c3N1cmVkSURSb290Q0EuY3J0MEUGA1UdHwQ+MDwwOqA4oDaGNGh0dHA6Ly9jcmwz
# LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcmwwEQYDVR0g
# BAowCDAGBgRVHSAAMA0GCSqGSIb3DQEBDAUAA4IBAQBwoL9DXFXnOF+go3QbPbYW
# 1/e/Vwe9mqyhhyzshV6pGrsi+IcaaVQi7aSId229GhT0E0p6Ly23OO/0/4C5+KH3
# 8nLeJLxSA8hO0Cre+i1Wz/n096wwepqLsl7Uz9FDRJtDIeuWcqFItJnLnU+nBgMT
# dydE1Od/6Fmo8L8vC6bp8jQ87PcDx4eo0kxAGTVGamlUsLihVo7spNU96LHc/RzY
# 9HdaXFSMb++hUD38dglohJ9vytsgjTVgHAIDyyCwrFigDkBjxZgiwbJZ9VVrzyer
# bHbObyMt9H5xaiNrIv8SuFQtJ37YOtnwtoeW/VvRXKwYw02fc7cBqZ9Xql4o4rmU
# MYIDdjCCA3ICAQEwdzBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs
# IEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5NiBTSEEy
# NTYgVGltZVN0YW1waW5nIENBAhAFRK/zlJ0IOaa/2z9f5WEWMA0GCWCGSAFlAwQC
# AQUAoIHRMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUx
# DxcNMjQwNTI5MDExODAzWjArBgsqhkiG9w0BCRACDDEcMBowGDAWBBRm8CsywsLJ
# D4JdzqqKycZPGZzPQDAvBgkqhkiG9w0BCQQxIgQgseKuswMET0eU0HsBlQoydru6
# 3af2BV/lOXc3nnDwCMQwNwYLKoZIhvcNAQkQAi8xKDAmMCQwIgQg0vbkbe10IszR
# 1EBXaEE2b4KK2lWarjMWr00amtQMeCgwDQYJKoZIhvcNAQEBBQAEggIAW9iJ/ovJ
# e01tDJ5DR7tn9AU4WTcgibzPmb/+cfkRievR5TJkCIUFdqkrfAMk46wZXjV6KixH
# I++U0GQjNh8RQSJ+2Yz9j6xAmvX6FKc/Xsd768TsHFVAPWYNwBIipkf49Cz9s3s8
# T7WcC1CikfXWzitn/lgwu6XXjj4qQZWyRsonUKSp+uIkZgZ/FXj33PUS3bjkEhhC
# euF+ZtOAfrlzpILSV+5XThYtsy4bSrNRzWH6r7xapBFQdMwp3iXM6va4T5Jesg0s
# mNyllqN0mbqWE8SfWaI9XSAtpe/nmDoAxIBBsuo7ua5IFfw64SWshoEldGqQzCQZ
# /fvBW1A4rUQqwyeUaUUSrhagtRd4D9/deMnACkpMhWOqymyoWR9acMsZPHWfQ1BP
# CWZUEx0okfm+NYFm1WK4tRTUlTEdwNFnSweXkVD98FVGvFidk1ZWRq59/GScgWsp
# ddA2SuSrcXMICZ9sxCfFOrS5j/sw1FixTNP6b7Rm8idhM3zJLjACj3xyNc1U1B/J
# KUfuNkzDvaPEDoHxdfQCzLhazx9Zyp2P0EtHRWG0iJd0ypx7JtRLFFIaOssEou9S
# z8o2qve5+9aJBrdzGxd+6KFnz+SP9cBauQL4aUHgocomAYLUMGpZ6jTmj8X9lTbl
# bIMtR0VgVRSCQdm9J5rQitG/0Ks2Njj9hvA=
# SIG # End signature block