Public/Invoke-NSPScanBridgeDeliveryDiscovery.ps1

function Invoke-NSPScanBridgeDeliveryDiscovery {
    <#
    .SYNOPSIS
        Observes how a scanner delivers test scans to a dedicated folder and saves a delivery profile.
    .DESCRIPTION
        Read-only: never moves, changes, claims or uploads files, and makes no network call. Files
        already in the folder are ignored. Every new file name is tracked, including temporary
        names, with watcher events, size/modified polling, a shared-read probe, an exclusive probe,
        Win32 error categories and the PDF structure check. Writes an events CSV and a timestamped
        profile JSON with classification, confidence, evidence, a conservative policy
        recommendation and simulator replay settings. -IncludeFileNames also writes a
        Filename,Event,Utc timing CSV for Compare-MfpScanTiming.ps1; without it, files are F1, F2...
    .EXAMPLE
        Invoke-NSPScanBridgeDeliveryDiscovery -Path 'C:\ScanTest' -DeviceName 'Front copier' -DurationSeconds 180
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [ValidateRange(5,3600)][double]$DurationSeconds=120,
        [ValidateRange(50,5000)][int]$PollMilliseconds=250,
        [ValidateRange(1,600)][double]$QuietSeconds=10,
        [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$')][string]$DeviceName='Scanner',
        [string]$OutputDirectory,
        [ValidateRange(1,10000)][int]$MaximumTrackedFiles=200,
        [switch]$IncludeFileNames,
        [switch]$PollingOnly
    )
    $folder=[IO.Path]::GetFullPath($Path).TrimEnd('\')
    if (-not (Test-Path -LiteralPath $folder -PathType Container)) { throw 'Discovery folder does not exist.' }
    if (-not $OutputDirectory) {
        $base=if($env:LOCALAPPDATA){$env:LOCALAPPDATA}else{[IO.Path]::GetTempPath()}
        $OutputDirectory=Join-Path $base 'NSP\ScanBridge\Discovery'
    }
    $output=[IO.Path]::GetFullPath($OutputDirectory).TrimEnd('\')
    foreach ($pair in @(@($output,$folder),@($folder,$output))) {
        if ($pair[0] -ieq $pair[1] -or $pair[0].StartsWith($pair[1]+'\',[StringComparison]::OrdinalIgnoreCase)) { throw 'The output directory and the discovery folder must not contain each other.' }
    }
    [void][IO.Directory]::CreateDirectory($output)
    $existing=@(Get-ChildItem -LiteralPath $folder -File -Force -ErrorAction Stop | ForEach-Object FullName)
    $session=[guid]::NewGuid().ToString('N')
    Write-Host 'Read-only delivery discovery: nothing is moved, changed, claimed or uploaded. Ctrl+C stops early and still saves results.'
    Write-Host 'Use a dedicated test folder, not a live route Source while the ScanBridge task is enabled. Probes briefly open each new file.'
    Write-Host ('Watching for {0}s, sampling every {1} ms; {2} existing file(s) ignored. Send the test scans now.' -f $DurationSeconds,$PollMilliseconds,$existing.Count)
    $observations=New-Object System.Collections.Generic.List[object]
    $seen=New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
    $start=[datetime]::UtcNow; $finished=$false
    try {
        Watch-NSPScanBridgeFileObservation -Sources @([pscustomobject]@{Name='Discovery';Path=$folder}) -PollMilliseconds $PollMilliseconds -DurationSeconds $DurationSeconds -MaximumTrackedFiles $MaximumTrackedFiles -PollingOnly:$PollingOnly -AllFiles -DeliveryProbe -IgnorePath $existing | ForEach-Object {
            $observations.Add($_)
            if ($_.Status -eq 'Present' -and $seen.Add($_.Path)) {
                $label=if($IncludeFileNames){[IO.Path]::GetFileName($_.Path) -replace '\p{Cc}','?'}else{'file with extension '+[IO.Path]::GetExtension($_.Path)}
                Write-Host ('{0,8:N1}s new {1}' -f ($_.ElapsedMs/1000),$label)
            }
        }
        $finished=$true
    } finally {
        $end=[datetime]::UtcNow
        # The observer's stopwatch starts after $start; anchor elapsed times to its actual UTC zero.
        $epoch=$start
        if ($observations.Count) {
            $zeros=@($observations | ForEach-Object { $_.ObservedUtc.AddMilliseconds(-[double]$_.ElapsedMs).Ticks } | Sort-Object)
            $epoch=New-Object DateTime ([long]$zeros[[int][math]::Floor(($zeros.Count-1)/2)]),([DateTimeKind]::Utc)
        }
        $endMs=($end-$epoch).TotalMilliseconds
        $built=Get-NSPScanBridgeDeliveryLifecycle -Observation @($observations.ToArray()) -PollMilliseconds $PollMilliseconds -QuietSeconds $QuietSeconds -EndMs $endMs
        $deliveryProfile=Get-NSPScanBridgeDeliveryProfile -Lifecycle @($built.Lifecycles) -DeviceName $DeviceName -SessionId $session -PollMilliseconds $PollMilliseconds -QuietSeconds $QuietSeconds -StartUtc $start -EndUtc $end -IgnoredExistingFiles $existing.Count -EndedEarly (-not $finished) -IncludeFileNames:$IncludeFileNames
        $stamp=$end.ToString('yyyyMMddTHHmmssZ')
        $eventsPath=Join-Path $output ('events_{0}.csv' -f $session)
        $profilePath=Join-Path $output ('profile_{0}_{1}.json' -f ($DeviceName -replace '[ .]','-'),$stamp)
        $rows=for ($i=0; $i -lt $observations.Count; $i++) {
            $o=$observations[$i]; $owner=$built.Owners[$i]
            [pscustomobject][ordered]@{
                SchemaVersion=1; SessionId=$session; Sequence=$i+1; Utc=$o.ObservedUtc.ToString('o'); LocalMonotonicMs=$o.ElapsedMs; Computer=if($IncludeFileNames){[Environment]::MachineName}else{$null}
                FileKey=if($owner){$owner.Key}else{$null}; Name=if($IncludeFileNames){[IO.Path]::GetFileName($o.Path)}else{$null}; Extension=[IO.Path]::GetExtension($o.Path)
                Event=$o.Source; Status=$o.Status; Bytes=$o.Length; LastWriteUtc=if($o.Status -eq 'Present'){$o.LastWriteTimeUtc.ToString('o')}else{$null}
                SharedRead=$o.SharedProbeStatus; SharedReadError=$o.SharedProbeError; Exclusive=$o.ProbeStatus; ExclusiveError=$o.ProbeError; StructureComplete=$o.StructureComplete
            }
        }
        if ($observations.Count) { @($rows) | Export-Csv -LiteralPath $eventsPath -NoTypeInformation -Encoding UTF8 } else { $eventsPath=$null }
        [IO.File]::WriteAllText($profilePath,($deliveryProfile | ConvertTo-Json -Depth 8),(New-Object Text.UTF8Encoding $false))
        $timingPath=$null
        if ($IncludeFileNames -and $built.Lifecycles.Count) {
            $timingPath=Join-Path $output ('timing_{0}.csv' -f $session)
            $timing=foreach ($l in $built.Lifecycles) {
                $name=$l.Names[$l.Names.Count-1]; $at={ param($ms) $epoch.AddMilliseconds($ms).ToString('o') }
                [pscustomobject]@{Filename=$name;Event='DiscoveryFirstSeen';Utc=(& $at $l.FirstMs)}
                if ($l.Rename) { [pscustomobject]@{Filename=$name;Event='DiscoveryRenamed';Utc=(& $at $l.Rename.ElapsedMs)} }
                [pscustomobject]@{Filename=$name;Event='DiscoveryLastChange';Utc=(& $at $l.LastChangeMs)}
                if ($null -ne $l.CompletedMs) { [pscustomobject]@{Filename=$name;Event='DiscoveryComplete';Utc=(& $at $l.CompletedMs)} }
            }
            @($timing) | Export-Csv -LiteralPath $timingPath -NoTypeInformation -Encoding UTF8
        }
        Write-Host ('Classification: {0} ({1} confidence). Suggested MinimumAgeSeconds: {2}.' -f $deliveryProfile.Classification,$deliveryProfile.Confidence,$deliveryProfile.Recommendation.MinimumAgeSeconds)
        foreach ($line in $deliveryProfile.Evidence) { Write-Host (' {0}' -f $line) }
        Write-Host ('Profile: {0}' -f $profilePath)
        if (-not $IncludeFileNames) { Write-Host 'File names were not recorded; add -IncludeFileNames to correlate with simulator logs.' }
        $deliveryProfile | Add-Member -NotePropertyMembers @{ProfilePath=$profilePath;EventsPath=$eventsPath;TimingPath=$timingPath} -PassThru
    }
}