PrtgSensorKit.psm1

#Region './Private/Format-PrtgMessage.ps1' -1

function Format-PrtgMessage {
  <#
  .SYNOPSIS
    Makes a string safe for a PRTG sensor message / error text.
  .DESCRIPTION
    PRTG does not support the number sign (#) in sensor messages and truncates messages at
    2000 characters. This strips '#' and truncates so the emitted text always conforms.
    Reference: https://www.paessler.com/manuals/prtg/custom_sensors
  #>

  [CmdletBinding()]
  [OutputType([string])]
  param(
    [AllowNull()]
    [AllowEmptyString()]
    [string]$Text
  )

  if ([string]::IsNullOrEmpty($Text)) { return '' }

  $clean = $Text.Replace('#', '')
  if ($clean.Length -gt 2000) { $clean = $clean.Substring(0, 2000) }
  return $clean
}
#EndRegion './Private/Format-PrtgMessage.ps1' 24
#Region './Private/Invoke-PrtgRelaunch.ps1' -1

function Invoke-PrtgRelaunch {
  # Re-launches the CALLING sensor script in another PowerShell host, then exits with its code.
  # Uses the caller's InvocationInfo (captured by the Restart-* function from the call stack)
  # rather than [Environment]::GetCommandLineArgs(): PRTG does not start sensors with
  # '-File script.ps1', so the process command line does not contain the script, but the caller's
  # InvocationInfo always exposes the script path and the parameters it was called with.
  #
  # Always relaunches via -File (not -Command): a script invoked as `powershell -Command "& 'x.ps1'"`
  # that calls `exit N` reports exit code 1, while -File reports N. -File preserves the exit code.
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [string]$Executable,

    [Parameter(Mandatory = $true)]
    [System.Management.Automation.InvocationInfo]$Invocation
  )

  $scriptPath = if ($Invocation.MyCommand -and $Invocation.MyCommand.Path) {
    $Invocation.MyCommand.Path
  } else {
    [string]$Invocation.InvocationName
  }
  if (-not $scriptPath) {
    throw "cannot determine the sensor script path to relaunch. Call Restart-* from a .ps1 file at the top level."
  }

  $newArgs = [System.Collections.Generic.List[string]]::new()
  '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass' | ForEach-Object { $newArgs.Add($_) }
  $newArgs.Add('-File')
  $newArgs.Add($scriptPath)

  # Forward the sensor's parameters so they survive the relaunch (PRTG "Parameters" field).
  foreach ($kv in $Invocation.BoundParameters.GetEnumerator()) {
    $val = $kv.Value
    if ($val -is [switch]) {
      if ($val.IsPresent) { $newArgs.Add("-$($kv.Key)") }
    } elseif ($val -is [System.Collections.IEnumerable] -and $val -isnot [string]) {
      $newArgs.Add("-$($kv.Key)")
      foreach ($item in $val) { $newArgs.Add([string]$item) }
    } else {
      $newArgs.Add("-$($kv.Key)")
      $newArgs.Add([string]$val)
    }
  }
  foreach ($u in $Invocation.UnboundArguments) { $newArgs.Add([string]$u) }

  & $Executable @newArgs
  exit $LASTEXITCODE
}
#EndRegion './Private/Invoke-PrtgRelaunch.ps1' 51
#Region './Private/PrtgState.ps1' -1

# Module-scope sensor output state. In the built single-file module every source file shares
# one script scope, so $script:OutputObject is visible to all functions without touching the
# caller's session. Clear-PrtgOutput re-initializes it (needed between runs and in tests).
$script:OutputObject = [PSCustomObject]@{
  prtg = [PSCustomObject]@{
    result = [System.Collections.ArrayList]@()
    text   = ''
  }
}
#EndRegion './Private/PrtgState.ps1' 10
#Region './Private/Set-PrtgSecretAcl.ps1' -1

function Set-PrtgSecretAcl {
  # Locks a secret file or its folder down to the account that created it, Administrators, and
  # SYSTEM. DPAPI already restricts *decryption* to the saving account; this is defence in depth
  # so other non-admin users cannot even read the encrypted blob. Windows-only (uses NTFS ACLs).
  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCompatibleCommands', '',
    Justification = 'Get-Acl/Set-Acl are Windows-only by design; this helper is only ever called on Windows (Save-PrtgSecret guards the call with Test-PrtgWindows).')]
  [CmdletBinding(SupportsShouldProcess = $true)]
  param(
    [Parameter(Mandatory = $true)]
    [string]$Path
  )

  $item = Get-Item -LiteralPath $Path
  $acl = Get-Acl -LiteralPath $Path

  # Disable inheritance and drop any inherited/explicit rules so only ours remain.
  $acl.SetAccessRuleProtection($true, $false)
  @($acl.Access) | ForEach-Object { [void]$acl.RemoveAccessRule($_) }

  $inherit = if ($item.PSIsContainer) { 'ContainerInherit,ObjectInherit' } else { 'None' }
  $identities = @(
    [System.Security.Principal.WindowsIdentity]::GetCurrent().User            # the saving account
    [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')       # BUILTIN\Administrators
    [System.Security.Principal.SecurityIdentifier]::new('S-1-5-18')           # NT AUTHORITY\SYSTEM
  )
  foreach ($id in $identities) {
    $rule = [System.Security.AccessControl.FileSystemAccessRule]::new(
      $id, 'FullControl', $inherit, 'None', 'Allow'
    )
    $acl.AddAccessRule($rule)
  }

  if ($PSCmdlet.ShouldProcess($Path, 'Restrict ACL to owner, Administrators, and SYSTEM')) {
    Set-Acl -LiteralPath $Path -AclObject $acl
  }
}
#EndRegion './Private/Set-PrtgSecretAcl.ps1' 37
#Region './Private/Test-PrtgWindows.ps1' -1

function Test-PrtgWindows {
  # True on Windows. On Windows PowerShell 5.1 $IsWindows is undefined, so PSEdition 'Desktop'
  # implies Windows; on PowerShell Core $IsWindows is authoritative.
  return (($PSVersionTable.PSEdition -eq 'Desktop') -or [bool]$IsWindows)
}
#EndRegion './Private/Test-PrtgWindows.ps1' 6
#Region './Public/Add-PrtgChannel.ps1' -1

function Add-PrtgChannel {
  <#
  .SYNOPSIS
    Adds a PRTG channel object to the current sensor output.

  .DESCRIPTION
    Appends a channel object to the module-scope result collection. Build output incrementally:
    add channels via this cmdlet (one per call, pipeline-friendly), then emit the JSON with
    Write-PrtgOutput. PRTG allows a maximum of 50 channels per sensor, so adding a 51st throws.

    Channel objects are typically created with New-PrtgChannel.

  .PARAMETER PrtgChannel
    A channel object (PSCustomObject) to add to the output. Usually from New-PrtgChannel.
    Accepts pipeline input.

  .EXAMPLE
    Get-Process | ForEach-Object { New-PrtgChannel -Channel $_.ProcessName -Value $_.CPU -Float } | Add-PrtgChannel
    Write-PrtgOutput

    Adds a channel per process (CPU value), then writes PRTG JSON output.

  .EXAMPLE
    New-PrtgChannel -Channel 'A' -Value 1 | Add-PrtgChannel
    New-PrtgChannel -Channel 'B' -Value 2 | Add-PrtgChannel

    Adds two channels one after the other.

  .INPUTS
    PSCustomObject. Channel objects from New-PrtgChannel (or compatible structure).

  .LINK
    New-PrtgChannel
  .LINK
    Write-PrtgOutput
  #>

  [CmdletBinding()]
  param(
    [Parameter(
      Mandatory = $true,
      ValueFromPipeline = $true
    )]
    [PSCustomObject]$PrtgChannel
  )

  process {
    if ($script:OutputObject.prtg.result.Count -ge 50) {
      throw "PRTG allows a maximum of 50 channels per sensor; refusing to add another."
    }

    [void] $script:OutputObject.prtg.result.Add($PrtgChannel)
  }
}
#EndRegion './Public/Add-PrtgChannel.ps1' 54
#Region './Public/Clear-PrtgOutput.ps1' -1

function Clear-PrtgOutput {
  <#
  .SYNOPSIS
    Resets the sensor output state to an empty result set.

  .DESCRIPTION
    Re-initializes the module-scope output object (channels and text). Because module state
    persists for the lifetime of the imported module, call this to start a fresh sensor output
    when reusing the same session (e.g. between test cases or multiple sensor runs in one host).

  .EXAMPLE
    Clear-PrtgOutput
    New-PrtgChannel -Channel 'A' -Value 1 | Add-PrtgChannel
    Write-PrtgOutput
  #>

  [CmdletBinding()]
  param()

  $script:OutputObject = [PSCustomObject]@{
    prtg = [PSCustomObject]@{
      result = [System.Collections.ArrayList]@()
      text   = ''
    }
  }
}
#EndRegion './Public/Clear-PrtgOutput.ps1' 26
#Region './Public/Get-PrtgMessage.ps1' -1

function Get-PrtgMessage {
  <#
  .SYNOPSIS
    Returns the current sensor message text.

  .DESCRIPTION
    Reads the 'text' currently set on the module-scope output object (see Set-PrtgMessage).

  .EXAMPLE
    Set-PrtgMessage 'Working'
    Get-PrtgMessage # -> 'Working'

  .OUTPUTS
    System.String. The current sensor message.

  .LINK
    Set-PrtgMessage
  #>

  return $script:OutputObject.prtg.text
}
#EndRegion './Public/Get-PrtgMessage.ps1' 21
#Region './Public/Get-PrtgSecret.ps1' -1

function Get-PrtgSecret {
  <#
  .SYNOPSIS
    Reads a secret previously stored with Save-PrtgSecret.

  .DESCRIPTION
    Loads a DPAPI-protected secret and returns it as a SecureString or PSCredential (whatever
    was saved). Decryption only succeeds when this runs as the SAME Windows account on the SAME
    machine that saved the secret - which for a sensor means the account PRTG runs it as.

    Windows only.

  .PARAMETER Name
    Identifier passed to Save-PrtgSecret.

  .PARAMETER Path
    Folder the secret was stored in. Defaults to '$env:ProgramData\PrtgSensorKit\Secrets'.

  .PARAMETER AsPlainText
    Return the secret as a plain [string] instead of a SecureString/PSCredential. For a stored
    SecureString this is the secret itself; for a stored PSCredential this is the password. Use
    only when an API requires the raw value - it defeats the point of keeping it a SecureString.

  .PARAMETER AllowUnprotected
    Development only. Allows reading a secret off Windows, where it was stored obfuscated rather
    than DPAPI-encrypted (see Save-PrtgSecret -AllowUnprotected). Never needed on a Windows sensor.

  .EXAMPLE
    $token = Get-PrtgSecret -Name 'AcmeApi' -AsPlainText
    Invoke-RestMethod -Uri $url -Headers @{ Authorization = "Bearer $token" }

    Reads a token for use in a web request.

  .EXAMPLE
    $cred = Get-PrtgSecret -Name 'SqlLogin'
    Invoke-Sqlcmd -Credential $cred -ServerInstance 'db01' -Query '...'

    Reads a stored credential and uses it directly.

  .LINK
    Save-PrtgSecret
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [ValidatePattern('^[A-Za-z0-9._-]+$')]
    [string]$Name,

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

    [Parameter(Mandatory = $false)]
    [switch]$AsPlainText,

    [Parameter(Mandatory = $false)]
    [switch]$AllowUnprotected
  )

  $onWindows = Test-PrtgWindows
  if (-not $onWindows -and -not $AllowUnprotected) {
    throw "PrtgSensorKit secret storage uses Windows DPAPI and NTFS ACLs; it is only available on Windows. Pass -AllowUnprotected to read an OBFUSCATED (not encrypted) development secret."
  }

  if ([string]::IsNullOrEmpty($Path)) {
    $Path = if ($onWindows) { Join-Path $env:ProgramData 'PrtgSensorKit\Secrets' }
            else { Join-Path ([System.IO.Path]::GetTempPath()) 'PrtgSensorKit/Secrets' }
  }

  $file = Join-Path $Path "$Name.clixml"
  if (-not (Test-Path -LiteralPath $file)) {
    throw "Secret '$Name' not found at '$file'. Save it first with Save-PrtgSecret, running as the same account this sensor runs as (e.g. Local System)."
  }

  try {
    $object = Import-Clixml -LiteralPath $file
  } catch {
    $who = if ($onWindows) { [System.Security.Principal.WindowsIdentity]::GetCurrent().Name } else { $env:USER }
    throw "Failed to decrypt secret '$Name'. DPAPI-protected secrets can only be read by the same Windows account and machine that saved them; this is running as '$who'. Re-save the secret as that account. ($($_.Exception.Message))"
  }

  if ($AsPlainText) {
    if ($object -is [System.Management.Automation.PSCredential]) {
      return $object.GetNetworkCredential().Password
    }
    if ($object -is [System.Security.SecureString]) {
      return [System.Net.NetworkCredential]::new('', $object).Password
    }
  }

  return $object
}
#EndRegion './Public/Get-PrtgSecret.ps1' 92
#Region './Public/Invoke-PrtgSensor.ps1' -1

function Invoke-PrtgSensor {
  <#
  .SYNOPSIS
    Runs a sensor script block and emits exactly one valid PRTG response.

  .DESCRIPTION
    The batteries-included way to write a sensor. Wrap your channel-building logic in a script
    block and Invoke-PrtgSensor handles all the boilerplate for you:

    - starts from a clean output state (calls Clear-PrtgOutput),
    - sets $ErrorActionPreference to 'Stop' so any failure is caught,
    - runs your block with its success and information output discarded, so a stray
      Write-Host or an un-captured command result cannot corrupt the sensor output,
    - emits the PRTG JSON once on success, or a PRTG error response if your block throws.

    Inside the block, build channels with New-PrtgChannel | Add-PrtgChannel and set the message
    with Set-PrtgMessage. Do NOT call Write-PrtgOutput / Write-PrtgError yourself and do NOT
    write to the output stream (see the note below) - the wrapper produces the single response.

  .PARAMETER ScriptBlock
    The sensor logic. Add one or more channels and set the message here; the block may contain
    any logic (loops, function calls, etc.). The wrapper emits the single result.

  .EXAMPLE
    Invoke-PrtgSensor {
      New-PrtgChannel -Channel 'CPU' -Value (Get-Counter '\Processor(_Total)\% Processor Time').CounterSamples.CookedValue -Unit Percent -Float | Add-PrtgChannel
      Set-PrtgMessage 'ok'
    }

    Builds a single channel and emits the PRTG JSON. If the block throws, a PRTG error is emitted instead.

  .EXAMPLE
    Invoke-PrtgSensor {
      # The block can contain any logic and add as many channels as you need (up to PRTG's 50).
      foreach ($disk in Get-CimInstance Win32_LogicalDisk -Filter 'DriveType = 3') {
        $freePct = [math]::Round($disk.FreeSpace / $disk.Size * 100, 1)
        New-PrtgChannel -Channel "Free % $($disk.DeviceID)" -Value $freePct -Unit Percent -Float `
          -LimitMinWarning 15 -LimitMinError 5 -LimitMode $true | Add-PrtgChannel
      }
      Set-PrtgMessage 'Disk free space per volume'
    }

    Builds one channel per fixed disk in a loop. Multiple channels and arbitrarily complex block
    logic (loops, function calls, remote queries) are fully supported - the wrapper only handles
    the surrounding error handling and single-response output.

  .NOTES
    A PRTG EXE/Script Advanced sensor reads its result from the process standard output, which
    must contain only the JSON. Anything your block writes to the output stream (a bare
    Write-Host, Write-Output, or an un-captured command that returns objects) would corrupt it,
    so the wrapper discards that output for you. For debugging, log to a file instead - never to
    the output stream.

    Import-Module works normally inside the block. Restart-As64BitPowershell / Restart-InPwsh do
    NOT: they relaunch the sensor as a child process, whose output would be discarded by this
    wrapper's output guard. Call them at the top of your script, BEFORE Invoke-PrtgSensor.

  .LINK
    New-PrtgChannel
  .LINK
    Write-PrtgError
  #>

  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true, Position = 0)]
    [scriptblock]$ScriptBlock
  )

  $ErrorActionPreference = 'Stop'
  Clear-PrtgOutput

  try {
    # Merge the information stream into success, then discard both so stray output from the
    # user's code never reaches stdout. Terminating errors still propagate to the catch below.
    & $ScriptBlock 6>&1 | Out-Null
    Write-PrtgOutput
  } catch {
    $_ | Write-PrtgError
  }
}
#EndRegion './Public/Invoke-PrtgSensor.ps1' 81
#Region './Public/New-PrtgChannel.ps1' -1

function New-PrtgChannel {
  <#
  .SYNOPSIS
    Creates a new PRTG Channel object for use in custom sensor JSON output.

  .DESCRIPTION
    New-PrtgChannel creates a PSCustomObject that represents a PRTG channel definition.
    The output can be piped to Write-PrtgOutput or Add-PrtgChannel to produce
    valid PRTG custom sensor JSON. Channel objects define how values are displayed,
    graphed, and evaluated (limits) in PRTG.

    Reference: https://www.paessler.com/manuals/prtg/custom_sensors#advanced_elements

    Dynamic parameters appear based on the -Unit value. Because they are dynamic, Get-Help
    only shows their descriptions once -Unit is bound; they are summarized here:
    - CustomUnit: mandatory when Unit is 'Custom'. Text shown after the value as its unit.
    - SpeedSize: when Unit is BytesBandwidth, SpeedDisk, or SpeedNet. Size prefix - one of
      One, Kilo, Mega, Giga, Tera, Byte, KiloByte, MegaByte, GigaByte, TeraByte, Bit, KiloBit,
      MegaBit, GigaBit, TeraBit.
    - SpeedTime: when Unit is BytesBandwidth, SpeedDisk, or SpeedNet. One of Second, Minute,
      Hour, Day.
    - VolumeSize: when Unit is BytesDisk or BytesFile. Same value set as SpeedSize.

  .PARAMETER Channel
    The display name of the channel in PRTG.

  .PARAMETER Value
    The numeric value for the channel. Supports int, int64, float, double, or decimal.

  .PARAMETER Unit
    The unit type for the channel. Determines formatting and available dynamic parameters.
    Default is 'Count'.

  .PARAMETER Mode
    Value mode: 'Absolute' (default in PRTG) or 'Difference' for delta display.

  .PARAMETER Float
    When specified, the value is sent as floating-point (recommended for decimal values).

  .PARAMETER DecimalMode
    Controls decimal places: 'Auto' or 'All'.

  .PARAMETER Warning
    When specified, marks the channel in warning state for PRTG.

  .PARAMETER ShowChart
    Whether to show the channel in the sensor graph. Default is $true.

  .PARAMETER ShowTable
    Whether to show the channel in the sensor table. Default is $true.

  .PARAMETER LimitMaxError
    Upper error limit. Value above this triggers error state.

  .PARAMETER LimitMaxWarning
    Upper warning limit. Value above this triggers warning state.

  .PARAMETER LimitMinWarning
    Lower warning limit. Value below this triggers warning state.

  .PARAMETER LimitMinError
    Lower error limit. Value below this triggers error state.

  .PARAMETER LimitErrorMsg
    Custom message shown when an error limit is exceeded.

  .PARAMETER LimitWarningMsg
    Custom message shown when a warning limit is exceeded.

  .PARAMETER LimitMode
    When $true, PRTG uses the defined limits for state evaluation. Default is $false.

  .PARAMETER ValueLookup
    Name of a PRTG value lookup to translate numeric values to text (e.g. "prtg.standardlookups.boolean").

  .PARAMETER NotifyChanged
    When specified, PRTG triggers change notification when the value changes.

  .PARAMETER CustomUnit
    Dynamic parameter, available and mandatory when -Unit is 'Custom'. The text displayed
    after the value as its unit.

  .PARAMETER SpeedSize
    Dynamic parameter, available when -Unit is 'BytesBandwidth', 'SpeedDisk', or 'SpeedNet'.
    The size prefix for the value (One, Kilo, Mega, Giga, Tera, Byte, KiloByte, MegaByte,
    GigaByte, TeraByte, Bit, KiloBit, MegaBit, GigaBit, TeraBit).

  .PARAMETER SpeedTime
    Dynamic parameter, available when -Unit is 'BytesBandwidth', 'SpeedDisk', or 'SpeedNet'.
    The time unit for the speed (Second, Minute, Hour, Day).

  .PARAMETER VolumeSize
    Dynamic parameter, available when -Unit is 'BytesDisk' or 'BytesFile'. The size prefix for
    the value (One, Kilo, Mega, Giga, Tera, Byte, KiloByte, MegaByte, GigaByte, TeraByte, Bit,
    KiloBit, MegaBit, GigaBit, TeraBit).

  .EXAMPLE
    New-PrtgChannel -Channel 'Total Items' -Value 42

    Creates a simple channel named "Total Items" with value 42 and unit Count.

  .EXAMPLE
    New-PrtgChannel -Channel 'CPU %' -Value 78.5 -Unit Percent -Float

    Creates a percentage channel with a floating-point value (e.g. CPU usage).

  .EXAMPLE
    New-PrtgChannel -Channel 'Temperature' -Value 65.2 -Unit Temperature -Float

    Creates a temperature channel (e.g. for hardware sensors).

  .EXAMPLE
    New-PrtgChannel -Channel 'Response Time' -Value 120 -Unit TimeResponse -LimitMaxWarning 100 -LimitMaxError 500 -LimitMode $true

    Creates a response time channel with warning above 100 ms and error above 500 ms.

  .EXAMPLE
    New-PrtgChannel -Channel 'Disk Free %' -Value 25.0 -Unit Percent -Float -LimitMinWarning 20 -LimitMinError 10 -LimitMode $true

    Creates a "Disk Free %" channel with lower limits (warning below 20%, error below 10%).

  .EXAMPLE
    New-PrtgChannel -Channel 'Success' -Value 950 -Unit Count | Add-PrtgChannel
    New-PrtgChannel -Channel 'Failed' -Value 50 -Unit Count -Warning | Add-PrtgChannel

    Creates two channels and writes them as PRTG JSON output (e.g. for a custom sensor script).

  .OUTPUTS
    PSCustomObject. A channel object with properties Channel, Value, Unit, ShowChart, ShowTable, and any specified optional parameters.

  .NOTES
    The returned object is designed to be consumed by Write-PrtgOutput or Add-PrtgChannel.
    Use -Float when passing decimal values to ensure correct PRTG JSON formatting.

  .LINK
    https://www.paessler.com/manuals/prtg/custom_sensors#advanced_elements
  .LINK
    Write-PrtgOutput
  .LINK
    Add-PrtgChannel
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
    Justification = 'Pure factory that returns a channel object; it changes no state, so -WhatIf/-Confirm do not apply.')]
  [CmdletBinding()]
  param(
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string]
    $Channel,

    [Parameter(Mandatory = $true)]
    [ValidateScript({
      $_ -is [int] -or
      $_ -is [int64] -or
      $_ -is [float] -or 
      $_ -is [double] -or 
      $_ -is [decimal]
    })]
    $Value,

    [Parameter(Mandatory = $false)]
    [ValidateSet(
      'BytesBandwidth',
      'BytesDisk',
      'Temperature',
      'Percent',
      'TimeResponse',
      'TimeSeconds',
      'Count',
      'Custom',
      'CPU',
      'BytesFile',
      'SpeedDisk',
      'SpeedNet',
      'TimeHours'
    )]
    [string]
    $Unit = 'Count',

    [Parameter(Mandatory = $false)]
    [ValidateSet('Absolute', 'Difference')]
    [string]
    $Mode,

    [Parameter(Mandatory = $false)]
    [switch]
    $Float,

    [Parameter(Mandatory = $false)]
    [ValidateSet('Auto', 'All')]
    [string]
    $DecimalMode,

    [Parameter(Mandatory = $false)]
    [switch]
    $Warning,

    [Parameter(Mandatory = $false)]
    [bool]
    $ShowChart = $true,

    [Parameter(Mandatory = $false)]
    [bool]
    $ShowTable = $true,
      
    [Parameter(Mandatory = $false)]
    [ValidateScript({
      $_ -is [int] -or
      $_ -is [int64] -or
      $_ -is [float] -or 
      $_ -is [double] -or 
      $_ -is [decimal]    -or
      $_ -is [string]
    })]
    $LimitMaxError,
      
    [Parameter(Mandatory = $false)]
    [ValidateScript({
      $_ -is [int] -or
      $_ -is [int64] -or
      $_ -is [float] -or 
      $_ -is [double] -or 
      $_ -is [decimal] -or
      $_ -is [string]
    })]
    $LimitMaxWarning,
      
    [Parameter(Mandatory = $false)]
    [ValidateScript({
      $_ -is [int] -or
      $_ -is [int64] -or
      $_ -is [float] -or 
      $_ -is [double] -or 
      $_ -is [decimal] -or 
      $_ -is [string]
    })]
    $LimitMinWarning,
      
    [Parameter(Mandatory = $false)]
    [ValidateScript({
      $_ -is [int] -or
      $_ -is [int64] -or
      $_ -is [float] -or 
      $_ -is [double] -or 
      $_ -is [decimal] -or 
      $_ -is [string]
    })]
    $LimitMinError,
      
    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]
    $LimitErrorMsg,
      
    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]
    $LimitWarningMsg,
      
    [Parameter(Mandatory = $false)]
    [bool]
    $LimitMode = $false,

    [Parameter(Mandatory = $false)]
    [ValidateNotNullOrEmpty()]
    [string]
    $ValueLookup,

    [Parameter(Mandatory = $false)]
    [switch] 
    $NotifyChanged
  )

  dynamicparam {
    $ParamDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::New()
    $ParameterAttribute = [System.Management.Automation.ParameterAttribute]::New()
    $ValidateSizeAttribute = [System.Management.Automation.ValidateSetAttribute]::New(
      'One',
      'Kilo',
      'Mega',
      'Giga',
      'Tera',
      'Byte',
      'KiloByte',
      'MegaByte',
      'GigaByte',
      'TeraByte',
      'Bit',
      'KiloBit',
      'MegaBit',
      'GigaBit',
      'TeraBit'
    )

    switch ( $Unit ) {
      { $_ -eq 'Custom' } { 
        $ParameterAttribute.Mandatory = $True
        $ValidateAttribute = [System.Management.Automation.ValidateNotNullOrEmptyAttribute]::New()
        $AttributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::New()
        $AttributeCollection.Add($ParameterAttribute)
        $AttributeCollection.Add($ValidateAttribute)
        $CustomUnitDynParam = [System.Management.Automation.RuntimeDefinedParameter]::New(
          'CustomUnit', [string], $AttributeCollection
        )
        $ParamDictionary.Add('CustomUnit', $CustomUnitDynParam)
      }
      { $_ -in @('BytesBandwidth', 'SpeedDisk', 'SpeedNet') } {
        $ParameterAttribute.Mandatory = $false
        $AttributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::New()
        $AttributeCollection.Add($ParameterAttribute)
        $AttributeCollection.Add($ValidateSizeAttribute)
        $SpeedSizeDynParam = [System.Management.Automation.RuntimeDefinedParameter]::New(
          'SpeedSize', [string], $AttributeCollection
        )
        $ParamDictionary.Add('SpeedSize', $SpeedSizeDynParam)

        $ValidateTimeAttribute = [System.Management.Automation.ValidateSetAttribute]::New(
          'Second',
          'Minute',
          'Hour',
          'Day'
        )
        $AttributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::New()
        $AttributeCollection.Add($ParameterAttribute)
        $AttributeCollection.Add($ValidateTimeAttribute)
        $SpeedTimeDynParam = [System.Management.Automation.RuntimeDefinedParameter]::New(
          'SpeedTime', [string], $AttributeCollection
        )
        $ParamDictionary.Add('SpeedTime', $SpeedTimeDynParam)
      }
      { $_ -in @('BytesDisk', 'BytesFile') } {
        $ParameterAttribute.Mandatory = $false
        $AttributeCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::New()
        $AttributeCollection.Add($ParameterAttribute)
        $AttributeCollection.Add($ValidateSizeAttribute)
        $VolumeSizeDynParam = [System.Management.Automation.RuntimeDefinedParameter]::New(
          'VolumeSize', [string], $AttributeCollection
        )

        $ParamDictionary.Add('VolumeSize', $VolumeSizeDynParam)
      }
    }

    return $ParamDictionary
  }
      
  process {
    $PrtgChannel = [PSCustomObject]@{
      PSTypeName = 'PrtgSensorKit.Channel'
      Channel    = $Channel
      Value      = $Value
      Unit       = $Unit
      ShowChart  = [int][bool]$ShowChart
      ShowTable  = [int][bool]$ShowTable
    }

    if (
      $Float -or
      $Value -is [float] -or 
      $Value -is [double] -or 
      $Value -is [decimal]
    ) {
      $PrtgChannel.Value = [double]$Value
      $PrtgChannel | Add-Member -Name 'Float' -Type NoteProperty -Value 1
    }

    $PSBoundParameters.GetEnumerator() | 
      Where-Object { $_.Key -notin 'Channel', 'Value', 'Unit', 'Float', 'ShowChart', 'ShowTable' } |
      ForEach-Object { 
        $TypeConvertedValue = switch ($_.Value) {
          { $_ -is [switch] } { [int][bool]$_ }
          { $_ -is [bool] } { [int]$_ }
          { $_ -is [string] } { $_ }
          default { "$_" }
        }

        # Limit messages are shown as sensor messages, so strip '#' and truncate to 2000 chars
        if ($_.Key -in 'LimitErrorMsg', 'LimitWarningMsg') {
          $TypeConvertedValue = Format-PrtgMessage $TypeConvertedValue
        }

        $PrtgChannel | Add-Member -Name $_.Key -Type NoteProperty -Value $TypeConvertedValue
      }

    return $PrtgChannel
  }
}
#EndRegion './Public/New-PrtgChannel.ps1' 388
#Region './Public/Restart-As64BitPowershell.ps1' -1

function Restart-As64BitPowershell {
  <#
  .SYNOPSIS
      Ensures the current script runs in 64-bit PowerShell.

  .DESCRIPTION
      PRTG and some hosts start PowerShell as a 32-bit (x86) Process. This function detects this and re-invokes
      the same command line in 64-bit PowerShell, then exits so the caller can rely on running in 64-bit.
      Call early in your script after importing the module (e.g. after Import-Module PrtgSensorKit).

  .EXAMPLE
      Import-Module PrtgSensorKit
      Restart-As64BitPowershell
      # rest of your sensor runs in 64-bit PowerShell

  .LINK
      Restart-InPwsh
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
    Justification = 'Non-interactive sensor bootstrap that relaunches the process; a -Confirm prompt would stall a PRTG probe.')]
  [CmdletBinding()]
  param()

  # Only restart when we're in 32-bit process on 64-bit Windows
  if (-not [Environment]::Is64BitProcess) {
    $Powershell64BitPath = "$env:WINDIR\sysnative\windowspowershell\v1.0\powershell.exe"
    if (-not (Test-Path -LiteralPath $Powershell64BitPath -PathType Leaf)) {
      Write-Warning "Restart-As64BitPowershell: 64-bit PowerShell not found at '$Powershell64BitPath'."
      return
    }

    try {
      # Relaunch the CALLING sensor script. From a module function, the caller's $MyInvocation is
      # not reachable via scope; take it from the call stack ([0] is this function, [1] the caller).
      $CallerInvocation = (Get-PSCallStack)[1].InvocationInfo
      Invoke-PrtgRelaunch -Executable $Powershell64BitPath -Invocation $CallerInvocation
    } catch {
      throw "Restart-As64BitPowershell: Failed to start 64-bit PowerShell: $_"
    }
  }
}
#EndRegion './Public/Restart-As64BitPowershell.ps1' 42
#Region './Public/Restart-InPwsh.ps1' -1

function Restart-InPwsh {
  <#
  .SYNOPSIS
      Ensures the current script runs in PowerShell 7+ (pwsh).

  .DESCRIPTION
      PRTG runs custom sensors in Windows PowerShell 5.1 (Desktop edition). If your sensor needs
      PowerShell 7+ features or modules, call this early (after Import-Module PrtgSensorKit) to
      re-invoke the same command line under pwsh, then exit so the caller can rely on running in
      pwsh. No-op when already on PowerShell Core. Warns and continues if pwsh is not installed.

      Can be combined with Restart-As64BitPowershell.

  .EXAMPLE
      Import-Module PrtgSensorKit
      Restart-InPwsh
      # rest of your sensor runs under pwsh

  .LINK
      Restart-As64BitPowershell
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
    Justification = 'Non-interactive sensor bootstrap that relaunches the process; a -Confirm prompt would stall a PRTG probe.')]
  [CmdletBinding()]
  param()

  # Desktop edition == Windows PowerShell 5.1; Core == pwsh 6+, so only act on Desktop
  if ($PSVersionTable.PSEdition -eq 'Desktop') {
    $Pwsh = Get-Command -Name 'pwsh' -CommandType Application -ErrorAction SilentlyContinue |
      Select-Object -First 1
    if (-not $Pwsh) {
      Write-Warning "Restart-InPwsh: PowerShell 7+ (pwsh) not found; continuing in Windows PowerShell."
      return
    }

    try {
      # Relaunch the CALLING sensor script. From a module function, the caller's $MyInvocation is
      # not reachable via scope; take it from the call stack ([0] is this function, [1] the caller).
      $CallerInvocation = (Get-PSCallStack)[1].InvocationInfo
      Invoke-PrtgRelaunch -Executable $Pwsh.Source -Invocation $CallerInvocation
    } catch {
      throw "Restart-InPwsh: Failed to start PowerShell 7+: $_"
    }
  }
}
#EndRegion './Public/Restart-InPwsh.ps1' 46
#Region './Public/Save-PrtgSecret.ps1' -1

function Save-PrtgSecret {
  <#
  .SYNOPSIS
    Saves a secret (SecureString or PSCredential) encrypted with Windows DPAPI for later use by a sensor.

  .DESCRIPTION
    Stores an API token, password, or full credential so a sensor never has to carry it in
    plain text. The secret is written with Export-Clixml, which protects it using Windows DPAPI:
    the file can only be decrypted by the SAME Windows account on the SAME machine that saved it.

    IMPORTANT - run this AS the account the sensor runs as. A PRTG custom sensor runs as Local
    System or the Windows credentials configured on the device/probe. You must save the secret
    while running as that same account, or Get-PrtgSecret will fail to decrypt it at sensor time.
    For Local System, run the save under Local System (for example via 'PsExec -s').

    Windows only. The store folder and file are ACL-locked to the saving account, Administrators,
    and SYSTEM.

  .PARAMETER Name
    Identifier for the secret. Used as the file name, so it is restricted to letters, digits,
    dot, dash, and underscore.

  .PARAMETER Secret
    The secret as a SecureString (e.g. an API token). Use this or -Credential.

  .PARAMETER Credential
    A full PSCredential (user name + password). Use this or -Secret.

  .PARAMETER Path
    Folder to store secrets in. Defaults to '$env:ProgramData\PrtgSensorKit\Secrets' on Windows,
    or a temp folder when -AllowUnprotected is used off Windows.

  .PARAMETER AllowUnprotected
    Development only. Off Windows there is no DPAPI, so Export-Clixml stores the secret merely
    OBFUSCATED (the UTF-16 bytes), not encrypted - anyone who can read the file can recover it.
    This switch opts in to that behaviour so you can exercise sensor logic on non-Windows; it
    prints a warning and never applies on a real Windows sensor host. Do not use for real secrets.

  .EXAMPLE
    Save-PrtgSecret -Name 'AcmeApi' -Secret (Read-Host -AsSecureString)

    Prompts for a token and stores it encrypted for the current account.

  .EXAMPLE
    Save-PrtgSecret -Name 'SqlLogin' -Credential (Get-Credential)

    Stores a user name and password for later use in a sensor.

  .NOTES
    DPAPI ties the encryption to the saving account and machine - the secret does not roam to
    other users or servers. Re-save it per machine / per sensor account.

  .LINK
    Get-PrtgSecret
  #>

  [CmdletBinding(DefaultParameterSetName = 'SecureString')]
  param(
    [Parameter(Mandatory = $true)]
    [ValidatePattern('^[A-Za-z0-9._-]+$')]
    [string]$Name,

    [Parameter(Mandatory = $true, ParameterSetName = 'SecureString')]
    [System.Security.SecureString]$Secret,

    [Parameter(Mandatory = $true, ParameterSetName = 'Credential')]
    [System.Management.Automation.PSCredential]$Credential,

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

    [Parameter(Mandatory = $false)]
    [switch]$AllowUnprotected
  )

  $onWindows = Test-PrtgWindows
  if (-not $onWindows) {
    if (-not $AllowUnprotected) {
      throw "PrtgSensorKit secret storage uses Windows DPAPI and NTFS ACLs; it is only available on Windows. Pass -AllowUnprotected to store an OBFUSCATED (not encrypted) secret for development only."
    }
    Write-Warning "Save-PrtgSecret: off Windows the secret is only OBFUSCATED, NOT encrypted (DPAPI is unavailable). Anyone who can read '$Name' can recover it. Use for development only - never for real credentials."
  }

  if ([string]::IsNullOrEmpty($Path)) {
    $Path = if ($onWindows) { Join-Path $env:ProgramData 'PrtgSensorKit\Secrets' }
            else { Join-Path ([System.IO.Path]::GetTempPath()) 'PrtgSensorKit/Secrets' }
  }

  if (-not (Test-Path -LiteralPath $Path)) {
    [void] (New-Item -ItemType Directory -Path $Path -Force)
  }

  $file = Join-Path $Path "$Name.clixml"
  $object = if ($PSCmdlet.ParameterSetName -eq 'Credential') { $Credential } else { $Secret }

  $object | Export-Clixml -LiteralPath $file -Force

  # NTFS ACL hardening is Windows-only; the DPAPI protection is what matters and only exists here.
  if ($onWindows) {
    Set-PrtgSecretAcl -Path $Path
    Set-PrtgSecretAcl -Path $file
  }

  if ($onWindows) {
    Write-Verbose "Saved secret '$Name' to '$file' (DPAPI-protected for account '$([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)')."
  } else {
    Write-Verbose "Saved OBFUSCATED (not encrypted) secret '$Name' to '$file'."
  }
}
#EndRegion './Public/Save-PrtgSecret.ps1' 109
#Region './Public/Set-PrtgMessage.ps1' -1

function Set-PrtgMessage {
  <#
  .SYNOPSIS
    Sets the sensor message text.

  .DESCRIPTION
    Sets the 'text' shown for the sensor in PRTG. The number sign (#) is stripped and the
    message is truncated to 2000 characters automatically, as PRTG requires.

  .PARAMETER Text
    The sensor message.

  .EXAMPLE
    Set-PrtgMessage 'All checks passed'

    Sets the sensor message.

  .LINK
    Get-PrtgMessage
  .LINK
    Write-PrtgOutput
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
    Justification = 'Only mutates in-memory module-scope output state; nothing persistent to confirm and sensors run non-interactively.')]
  [CmdletBinding()]
  param(
    [string]$Text
  )

  $script:OutputObject.prtg.text = Format-PrtgMessage $Text
}
#EndRegion './Public/Set-PrtgMessage.ps1' 32
#Region './Public/Set-PrtgOutput.ps1' -1

function Set-PrtgOutput {
  <#
  .SYNOPSIS
    Replaces the entire sensor output object.

  .DESCRIPTION
    Overwrites the module-scope output object with the one provided. Most sensors never need
    this - build output with New-PrtgChannel / Add-PrtgChannel instead. Use it only
    when you want to supply a fully pre-built object with the PRTG shape (a 'prtg' property
    containing 'result' and 'text').

  .PARAMETER Object
    The replacement output object. Must have the PRTG structure for Add-PrtgChannel,
    Set-PrtgMessage, and Write-PrtgOutput to keep working.

  .EXAMPLE
    $custom = [PSCustomObject]@{ prtg = [PSCustomObject]@{ result = [System.Collections.ArrayList]@(); text = '' } }
    Set-PrtgOutput $custom

    Replaces the current output object with a custom one.

  .LINK
    Clear-PrtgOutput
  .LINK
    Write-PrtgOutput
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
    Justification = 'Only replaces the in-memory module-scope output object; nothing persistent to confirm and sensors run non-interactively.')]
  [CmdletBinding()]
  param(
    $Object
  )

  $script:OutputObject = $Object
}
#EndRegion './Public/Set-PrtgOutput.ps1' 36
#Region './Public/Write-PrtgError.ps1' -1

function Write-PrtgError {
  <#
  .SYNOPSIS
    Outputs an error to PRTG.

  .DESCRIPTION
    Emits a PRTG error response (JSON) that replaces all channel data with a single error
    message. The number sign (#) is stripped and the message is truncated to 2000 characters,
    as PRTG requires.

  .PARAMETER ErrorObject
    The error object to output. This is typically the $_ variable in a try/catch block or
    the $Error[0] variable. The emitted message includes line, char, message, and source line.

  .PARAMETER ErrorString
    A custom error message to output.

  .EXAMPLE
    Write-PrtgError -ErrorObject $_

    Reports a caught error (from a trap or catch block) to PRTG.

  .EXAMPLE
    Write-PrtgError -ErrorString "My error message"

    Reports a custom error message to PRTG.

  .LINK
    Write-PrtgOutput
  #>

  [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseProcessBlockForPipelineCommand', '',
    Justification = 'A PRTG sensor returns exactly one error response. Deliberately no process block, so piping multiple errors yields a single response, not one per error.')]
  [CmdletBinding()]
  param (
    [Parameter(
      Mandatory = $true,
      ValueFromPipeline = $true,
      ParameterSetName = 'ErrorObject'
    )]
    [System.Management.Automation.ErrorRecord]
    $ErrorObject,
    [Parameter(
      Mandatory = $true,
      ValueFromPipeline = $true,
      ParameterSetName = 'ErrorString'
    )]
    [string]
    $ErrorString
  )

  $RawText = if ($PsCmdlet.ParameterSetName -eq 'ErrorObject') {
    "line:$($ErrorObject.InvocationInfo.ScriptLineNumber.ToString()) " +
    "char:$($ErrorObject.InvocationInfo.OffsetInLine.ToString()) --- " +
    "message: $($ErrorObject.Exception.Message.ToString()) --- " +
    "line: $($ErrorObject.InvocationInfo.Line.ToString())"
  } else {
    $ErrorString
  }

  $ErrorOutput = [PSCustomObject]@{
    prtg = [PSCustomObject]@{
      error = 1
      text  = Format-PrtgMessage $RawText
    }
  }

  [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
  Write-Output ($ErrorOutput | ConvertTo-Json -Depth 10)
}
#EndRegion './Public/Write-PrtgError.ps1' 70
#Region './Public/Write-PrtgOutput.ps1' -1

function Write-PrtgOutput {
  <#
  .SYNOPSIS
    Emits the current sensor output as PRTG JSON.

  .DESCRIPTION
    Serializes the module-scope output object (channels and message) to the JSON structure
    PRTG expects and writes it to the output stream. Call this once, last, after adding all
    channels with Add-PrtgChannel and optionally setting text with Set-PrtgMessage.

  .EXAMPLE
    New-PrtgChannel -Channel 'A' -Value 1 | Add-PrtgChannel
    Write-PrtgOutput

    Emits a sensor result containing a single channel.

  .OUTPUTS
    System.String. The PRTG sensor JSON.

  .LINK
    Add-PrtgChannel
  .LINK
    Set-PrtgMessage
  #>

  [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
  Write-Output ($script:OutputObject | ConvertTo-Json -Depth 10)
}
#EndRegion './Public/Write-PrtgOutput.ps1' 28