<# .SYNOPSIS Installs (or upgrades) a UAP Finder capture head on this Windows PC in one step. .DESCRIPTION One command, from any PowerShell: irm https://uapfinder.com/install.ps1 | iex It relaunches itself elevated if it has to, installs FFmpeg when it is missing (winget), downloads the latest capture-service release from the PUBLIC feed and verifies it against the SHA-256 the feed publishes (the same check every installed head makes before it self-updates), unpacks it to C:\UAPFinder\svc, registers the Windows service UAPFinderCapture (auto-start, restart on crash, the two firewall rules and the desktop shortcut - via the install-service.ps1 that ships in the package), asks for the camera's address and login the first time, and opens the dashboard. No account is needed to install: the feed is public. Signing in happens afterwards, on the dashboard, and is what pairs the camera with you. Running it again on a machine that already has a head UPGRADES it in place. appsettings.Local.json - your camera login, capture folder, location, account link - is never rewritten once it exists, and neither are head-cert.pfx or installation-id.txt; the same three files the self-updater preserves. This script carries no secrets and nothing about any particular machine. Anything it needs to know about yours it asks for, or takes from a parameter. .PARAMETER InstallDir Where the service lives. Default C:\UAPFinder\svc. .PARAMETER CameraHost The camera's IP address or host name (RTSP). Prompted for when omitted and no Local config exists. .PARAMETER CameraUser The camera's RTSP user. Default admin. .PARAMETER CameraPassword The camera's RTSP password. Pass a SecureString to keep it out of your shell history; a plain string is accepted with a warning. .PARAMETER CapturesDir Where clips and event records go. Default %ProgramData%\UAPFinder\captures. Pick a drive with room. .PARAMETER Schedule Night (capture only when the sun is below the horizon at the camera - needs a location, which you set from the phone or the website afterwards) or Always. Default Night. .PARAMETER AddCamera Install ANOTHER camera on this PC instead of updating the existing one (ADR 0094). The new camera gets its own copy of the service - its own folder (C:\UAPFinder\svc2, svc3, ...), Windows service (UAPFinderCapture2, ...), dashboard port (5200, 5201, ...), HTTPS port and capture folder - and takes the first camera's account link, location and schedule so it does not need setting up twice. It then offers to link the two cameras so both save the same moments (point them at the same patch of sky). Running the installer on a PC that already has a camera, without this switch, asks whether to update or add. .PARAMETER CameraBrand Which stream paths the camera uses: Reolink (default), Dahua (also EmpireTech, Amcrest, Lorex), Hikvision (also ANNKE, Hilook), or Other (you are asked for the two RTSP paths). Asked when omitted. .PARAMETER CameraName The new camera's name in a rig ("Camera 2", "North"). Default "Camera N". .PARAMETER NoLink With -AddCamera: do not link the new camera with the existing one(s). .PARAMETER DryRun Print everything the script would do - the release it would install, the files it would write, the service actions - and change nothing. Does not need elevation. .PARAMETER NoBrowser Do not open the dashboard at the end (unattended installs). .PARAMETER NonInteractive Never prompt. Anything not supplied as a parameter is left for the dashboard's Camera panel. .EXAMPLE irm https://uapfinder.com/install.ps1 | iex .EXAMPLE # Unattended, with parameters. iex cannot take arguments, so bind the text to a script block: & ([scriptblock]::Create((irm https://uapfinder.com/install.ps1))) -CameraHost 192.168.1.50 ` -CameraUser admin -CameraPassword (Read-Host -AsSecureString "camera password") ` -CapturesDir D:\UAPFinder\captures -Schedule Night -NoBrowser .EXAMPLE .\install-capture-head.ps1 -DryRun .EXAMPLE # A second camera on the same PC, aimed at the same sky as the first: & ([scriptblock]::Create((irm https://uapfinder.com/install.ps1))) -AddCamera -CameraHost 192.168.1.51 -CameraBrand Dahua #> [CmdletBinding()] param( [string]$InstallDir = 'C:\UAPFinder\svc', [string]$CameraHost, [string]$CameraUser = 'admin', [object]$CameraPassword, [string]$CapturesDir, [ValidateSet('Night', 'Always')] [string]$Schedule = 'Night', [string]$FeedUrl = 'https://api.uapfinder.com/api/releases/capture-service.json', [string]$ScriptUrl = 'https://uapfinder.com/install.ps1', [string]$ServiceName = 'UAPFinderCapture', [string]$DashboardUrl = 'http://localhost:5199', [switch]$AddCamera, [ValidateSet('Reolink', 'Dahua', 'Hikvision', 'Other')] [string]$CameraBrand, [string]$CameraName, [switch]$NoLink, [switch]$DryRun, [switch]$NoBrowser, [switch]$NonInteractive ) $ErrorActionPreference = 'Stop' if ($PSVersionTable.PSVersion.Major -lt 6) { # Windows PowerShell 5.1 defaults to TLS 1.0 and cannot reach the feed without this. [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } $ProgressPreference = 'SilentlyContinue' # Invoke-WebRequest's progress bar makes 5.1 downloads crawl $Preserve = @('appsettings.Local.json', 'head-cert.pfx', 'installation-id.txt') if (-not $CapturesDir) { $CapturesDir = Join-Path $env:ProgramData 'UAPFinder\captures' } function Say($m) { Write-Host $m } function Step($m) { Write-Host ""; Write-Host "== $m" -ForegroundColor Cyan } function Plan($m) { if ($DryRun) { Write-Host " [dry run] would $m" -ForegroundColor Yellow } } function Fail($m) { Write-Host ""; Write-Host "STOPPED: $m" -ForegroundColor Red; exit 1 } # JSON the service reads: UTF-8 WITHOUT a byte-order mark. Windows PowerShell 5.1's Set-Content -Encoding UTF8 # always writes one, and the service used to crash at start on it. function Write-Utf8NoBom { param([string]$Path, [Parameter(ValueFromPipeline = $true)][string]$Text) process { [IO.File]::WriteAllText($Path, $Text, (New-Object Text.UTF8Encoding $false)) } } # This script as a file, for running it again in a child process (-AddCamera updates the first camera that # way). Under `irm | iex` there is no file, so its own text is saved; the published copy is the fallback. $SelfText = $null try { $SelfText = $MyInvocation.MyCommand.ScriptBlock.ToString() } catch { } function Get-SelfPath { if ($PSCommandPath) { return $PSCommandPath } $p = Join-Path ([IO.Path]::GetTempPath()) 'uapfinder-install-capture-head.ps1' if ($SelfText -and $SelfText.Contains('UAPFinderCapture')) { Set-Content -Path $p -Value $SelfText -Encoding UTF8 } else { Invoke-WebRequest -Uri $ScriptUrl -OutFile $p -UseBasicParsing } return $p } # --- elevation --------------------------------------------------------------------------------------- $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) $elevated = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $elevated -and -not $DryRun) { Step "Administrator rights are needed to install a Windows service - relaunching elevated" # Under `irm | iex` there is no file to relaunch, so the script is saved first. Its own text is # preferred; the published copy is the fallback. $self = $PSCommandPath if (-not $self) { $self = Join-Path ([IO.Path]::GetTempPath()) 'uapfinder-install-capture-head.ps1' $text = $null try { $text = $MyInvocation.MyCommand.ScriptBlock.ToString() } catch { } if ($text -and $text.Contains('UAPFinderCapture')) { Set-Content -Path $self -Value $text -Encoding UTF8 } else { Invoke-WebRequest -Uri $ScriptUrl -OutFile $self -UseBasicParsing } } $forward = @() foreach ($k in $PSBoundParameters.Keys) { $v = $PSBoundParameters[$k] if ($v -is [switch]) { if ($v) { $forward += "-$k" }; continue } if ($k -eq 'CameraPassword') { # A password cannot cross the elevation boundary safely on a command line; the elevated # copy asks for it again. Write-Warning "The camera password will be asked for again in the elevated window." continue } $forward += "-$k"; $forward += "`"$v`"" } $shell = if ($PSVersionTable.PSVersion.Major -ge 6) { 'pwsh' } else { 'powershell' } $argList = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-NoExit', '-File', "`"$self`"") + $forward Start-Process -FilePath $shell -Verb RunAs -ArgumentList $argList Say "Continue in the elevated window." return } if ($DryRun) { Say "DRY RUN - nothing on this machine will change." if (-not $elevated) { Say "(not elevated; a real run would relaunch as administrator first)" } } # --- cameras already on this PC (ADR 0094: one copy of the service per camera) ------------------------ function Read-LocalConfig($dir) { $p = Join-Path $dir 'appsettings.Local.json' if (-not (Test-Path $p)) { return $null } try { return Get-Content $p -Raw | ConvertFrom-Json } catch { return $null } } function Get-PortFromUrls($urls, $fallback) { if ($urls -and ([string]$urls) -match ':(\d+)/?(;|$)') { return [int]$Matches[1] } return $fallback } function Get-InstalledCameras { $found = @() $services = @(Get-CimInstance Win32_Service -Filter "Name LIKE 'UAPFinderCapture%'" -ErrorAction SilentlyContinue) foreach ($s in $services) { $exePath = ([string]$s.PathName).Trim().Trim('"') if (-not $exePath -or -not (Test-Path $exePath)) { continue } $dir = Split-Path $exePath $cfg = Read-LocalConfig $dir $found += [pscustomobject]@{ Service = $s.Name Dir = $dir Port = Get-PortFromUrls $cfg.Urls 5199 HttpsPort = if ($cfg.RemoteAccess.HttpsPort) { [int]$cfg.RemoteAccess.HttpsPort } else { 5443 } Captures = [string]$cfg.Storage.OutputDirectory RigName = [string]$cfg.Rig.CameraName Config = $cfg } } return $found | Sort-Object Port } $installed = @(Get-InstalledCameras) $installedHere = (Test-Path (Join-Path $InstallDir 'UAPFinder.CaptureService.exe')) -or ($installed | Where-Object { $_.Service -eq $ServiceName }) if (-not $AddCamera -and $installedHere -and -not $NonInteractive -and -not $DryRun) { Say "" Say "A UAP Finder camera is already installed on this PC ($InstallDir)." $choice = Read-Host " [U]pdate it, or [A]dd another camera (its own copy, dashboard and capture folder)? [U]" if ($choice -match '^a') { $AddCamera = $true } } $first = $null if ($AddCamera) { Step "Another camera on this PC" $first = $installed | Where-Object { $_.Service -eq 'UAPFinderCapture' } | Select-Object -First 1 if (-not $first) { $first = $installed | Select-Object -First 1 } if (-not $first) { Fail "No UAP Finder camera is installed on this PC yet. Run the installer without -AddCamera first; that installs camera 1." } $n = 2 $baseDir = $first.Dir.TrimEnd('\') while ((Get-Service -Name "UAPFinderCapture$n" -ErrorAction SilentlyContinue) -or (Test-Path "$baseDir$n")) { $n++ } if (-not $PSBoundParameters.ContainsKey('InstallDir')) { $InstallDir = "$baseDir$n" } if (-not $PSBoundParameters.ContainsKey('ServiceName')) { $ServiceName = "UAPFinderCapture$n" } if (-not $CameraName) { $CameraName = "Camera $n" } # Ports nobody uses: not another camera's, not anything listening right now. $taken = @($installed | ForEach-Object { $_.Port; $_.HttpsPort }) try { $taken += @(Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | ForEach-Object { $_.LocalPort }) } catch { } $httpPort = 5200; while ($taken -contains $httpPort) { $httpPort++ } $taken += $httpPort $httpsPort = 5444; while ($taken -contains $httpsPort) { $httpsPort++ } if (-not $PSBoundParameters.ContainsKey('DashboardUrl')) { $DashboardUrl = "http://localhost:$httpPort" } else { $httpPort = ([Uri]$DashboardUrl).Port } if (-not $PSBoundParameters.ContainsKey('CapturesDir')) { $CapturesDir = if ($first.Captures) { $first.Captures.TrimEnd('\') + "-cam$n" } else { Join-Path $env:ProgramData "UAPFinder\captures-cam$n" } } if (-not $PSBoundParameters.ContainsKey('Schedule') -and $first.Config.Schedule.Mode -in @('Night', 'Always')) { $Schedule = [string]$first.Config.Schedule.Mode } Say " existing camera: $($first.Service) in $($first.Dir), dashboard http://localhost:$($first.Port)" Say " new camera: $ServiceName in $InstallDir" Say " dashboard $DashboardUrl, HTTPS port $httpsPort" Say " capture folder $CapturesDir" } # --- FFmpeg ----------------------------------------------------------------------------------------- # The service runs as LocalSystem, which does not see a user's PATH, so whatever is found is written as an # ABSOLUTE path into the head's own config. Bare "ffmpeg" works in a console and fails as a service. function Find-Ffmpeg { $roots = @( (Join-Path $env:ProgramFiles 'WinGet\Packages'), (Join-Path $env:LOCALAPPDATA 'Microsoft\WinGet\Packages'), 'C:\ffmpeg\bin', 'C:\ProgramData\chocolatey\bin' ) $cmd = Get-Command ffmpeg.exe -ErrorAction SilentlyContinue if ($cmd) { $p = $cmd.Source # winget's Links dir holds a reparse point; the service wants the real file behind it. try { $item = Get-Item $p; if ($item.Target) { $p = [string]$item.Target } } catch { } if (Test-Path $p) { return $p } } foreach ($r in $roots) { if (-not (Test-Path $r)) { continue } $hit = Get-ChildItem $r -Recurse -Filter ffmpeg.exe -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($hit) { return $hit.FullName } } return $null } Step "FFmpeg" $ffmpeg = Find-Ffmpeg if ($ffmpeg) { Say " found: $ffmpeg" } else { $winget = Get-Command winget -ErrorAction SilentlyContinue if (-not $winget) { Fail "FFmpeg is not installed and winget is not available. Install FFmpeg (https://www.gyan.dev/ffmpeg/builds/ - the 'essentials' build), put its bin folder on PATH or note where it is, then run this again." } Plan "run: winget install Gyan.FFmpeg --scope machine --accept-source-agreements --accept-package-agreements" if (-not $DryRun) { Say " installing Gyan.FFmpeg with winget (machine scope, so the service account can see it)..." & winget install --id Gyan.FFmpeg --exact --scope machine --accept-source-agreements --accept-package-agreements --disable-interactivity | Out-Host $ffmpeg = Find-Ffmpeg if (-not $ffmpeg) { Fail "winget did not leave an ffmpeg.exe where this script can find it. Install FFmpeg by hand (https://www.gyan.dev/ffmpeg/builds/), then run this again; or set Camera:FfmpegPath / Camera:FfprobePath in $InstallDir\appsettings.Local.json yourself." } Say " installed: $ffmpeg" } } $ffprobe = $null if ($ffmpeg) { $ffprobe = Join-Path (Split-Path $ffmpeg) 'ffprobe.exe' if (-not (Test-Path $ffprobe)) { Fail "ffprobe.exe is not beside $ffmpeg - the service needs both. Install a full FFmpeg build." } } # --- the .NET runtime the package runs on -------------------------------------------------------------- # The release is framework-dependent (small, and it self-updates without re-shipping a runtime), so the # machine needs the ASP.NET Core 10 shared runtime. Without it the service is installed fine and then # dies on first start with Windows' own dialog, which nobody sees under LocalSystem. Step ".NET runtime" function Test-AspNetRuntime { $dotnet = Get-Command dotnet -ErrorAction SilentlyContinue if (-not $dotnet) { return $false } $list = & $dotnet.Source --list-runtimes 2>$null return [bool]($list | Where-Object { $_ -match '^Microsoft\.AspNetCore\.App 10\.' }) } if (Test-AspNetRuntime) { Say " found: Microsoft.AspNetCore.App 10.x" } else { $winget = Get-Command winget -ErrorAction SilentlyContinue if (-not $winget) { Fail "The ASP.NET Core 10 runtime is not installed and winget is not available. Install it from https://dotnet.microsoft.com/download/dotnet/10.0 (the 'ASP.NET Core Runtime' Hosting Bundle or x64 installer), then run this again." } Plan "run: winget install Microsoft.DotNet.AspNetCore.10 --accept-source-agreements --accept-package-agreements" if (-not $DryRun) { Say " installing the ASP.NET Core 10 runtime with winget..." & winget install --id Microsoft.DotNet.AspNetCore.10 --exact --accept-source-agreements --accept-package-agreements --disable-interactivity | Out-Host # A fresh install lands on PATH for new processes only; look in the default location too. $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') if (-not (Test-AspNetRuntime)) { Fail "winget finished but 'dotnet --list-runtimes' still shows no Microsoft.AspNetCore.App 10.x. Install it from https://dotnet.microsoft.com/download/dotnet/10.0, then run this again." } Say " installed." } } # --- the latest release, verified -------------------------------------------------------------------- Step "Latest release" try { $manifest = Invoke-RestMethod -Uri $FeedUrl -UseBasicParsing -TimeoutSec 60 } catch { Fail "Could not read the release feed at $FeedUrl : $($_.Exception.Message)" } foreach ($k in 'version', 'commit', 'url', 'sha256', 'publishedUtc') { if (-not $manifest.$k) { Fail "The release feed is missing '$k'; refusing to install from it." } } $pkgUri = [Uri]$manifest.url if ($pkgUri.Scheme -ne 'https' -and -not $pkgUri.IsLoopback) { Fail "The package URL is not https ($($manifest.url)); refusing." } if ($manifest.sha256 -notmatch '^[0-9a-fA-F]{64}$') { Fail "The release publishes no usable SHA-256, so the download could not be verified; refusing." } Say " version: $($manifest.version)" Say " commit: $($manifest.commit)" Say " published: $($manifest.publishedUtc)" if ($manifest.notes) { Say " notes: $($manifest.notes)" } $installedRecord = Join-Path $InstallDir 'installed-update.json' $exe = Join-Path $InstallDir 'UAPFinder.CaptureService.exe' $alreadyCurrent = $false if (Test-Path $installedRecord) { try { $rec = Get-Content $installedRecord -Raw | ConvertFrom-Json if ($rec.Commit -and ([string]$manifest.commit).StartsWith([string]$rec.Commit, [StringComparison]::OrdinalIgnoreCase)) { $alreadyCurrent = $true } } catch { } } if ($alreadyCurrent -and (Test-Path $exe)) { Say " $InstallDir already runs this release; the files will be left alone." } # Linking needs the existing camera on a release that knows how; bring it up to this one first. if ($AddCamera -and $first) { $firstCurrent = $false try { $fr = Get-Content (Join-Path $first.Dir 'installed-update.json') -Raw | ConvertFrom-Json if ($fr.Commit -and ([string]$manifest.commit).StartsWith([string]$fr.Commit, [StringComparison]::OrdinalIgnoreCase)) { $firstCurrent = $true } } catch { } if ($firstCurrent) { Say " $($first.Service) already runs this release." } else { Plan "update $($first.Service) in $($first.Dir) to this release first (a child run of this script, nothing else changed)" if (-not $DryRun) { Say " updating $($first.Service) to this release first, so the two can be linked..." $shell = if ($PSVersionTable.PSVersion.Major -ge 6) { 'pwsh' } else { 'powershell' } & $shell -NoProfile -ExecutionPolicy Bypass -File (Get-SelfPath) -InstallDir $first.Dir -ServiceName $first.Service ` -DashboardUrl "http://localhost:$($first.Port)" -FeedUrl $FeedUrl -NonInteractive -NoBrowser | Out-Host if ($LASTEXITCODE -ne 0) { Fail "Updating $($first.Service) did not finish (see above). Nothing was installed for the new camera." } } } } $work = Join-Path ([IO.Path]::GetTempPath()) "uapfinder-install-$([IO.Path]::GetRandomFileName())" $zip = Join-Path $work 'package.zip' $stage = Join-Path $work 'package' function Extract-Safely($zipPath, $destination) { # Refuse any entry that would land outside the destination (a zip is a program here). Add-Type -AssemblyName System.IO.Compression.FileSystem $root = (New-Item -ItemType Directory -Path $destination -Force).FullName.TrimEnd('\') + '\' $archive = [IO.Compression.ZipFile]::OpenRead($zipPath) try { foreach ($entry in $archive.Entries) { if (-not $entry.Name) { continue } # directory entry $target = [IO.Path]::GetFullPath((Join-Path $root $entry.FullName)) if (-not $target.StartsWith($root, [StringComparison]::OrdinalIgnoreCase)) { throw "The package contains an entry that escapes its folder ($($entry.FullName)); refusing it." } New-Item -ItemType Directory -Path (Split-Path $target) -Force | Out-Null [IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $target, $true) } } finally { $archive.Dispose() } } if (-not ($alreadyCurrent -and (Test-Path $exe))) { Plan "download $($manifest.url), verify SHA-256 $($manifest.sha256), unpack to $InstallDir (keeping $($Preserve -join ', '))" if (-not $DryRun) { New-Item -ItemType Directory -Path $work -Force | Out-Null Say " downloading $($manifest.url)" Invoke-WebRequest -Uri $manifest.url -OutFile $zip -UseBasicParsing -TimeoutSec 600 $actual = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() if ($actual -ne ([string]$manifest.sha256).ToLowerInvariant()) { Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue Fail "The download hashes to $actual but the feed published $($manifest.sha256). Nothing was unpacked." } Say " SHA-256 verified" Extract-Safely $zip $stage if (-not (Test-Path (Join-Path $stage 'UAPFinder.CaptureService.exe'))) { Fail "The package has no UAPFinder.CaptureService.exe in it." } } } # --- stop the service if it is running ----------------------------------------------------------------- Step "Windows service $ServiceName" $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue $needsFiles = -not ($alreadyCurrent -and (Test-Path $exe)) if ($svc) { Say " exists (status $($svc.Status))" if ($needsFiles -and $svc.Status -ne 'Stopped') { Plan "stop $ServiceName while its files are replaced" if (-not $DryRun) { Stop-Service $ServiceName -Force for ($i = 0; $i -lt 30 -and (Get-Service $ServiceName).Status -ne 'Stopped'; $i++) { Start-Sleep -Seconds 1 } Start-Sleep -Seconds 3 # file locks outlive the stopped status for a moment } } } else { Say " not installed yet" } # --- files --------------------------------------------------------------------------------------------- if ($needsFiles) { Plan "copy the package over $InstallDir (a copy of the build it replaces is kept beside it)" if (-not $DryRun) { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null if (Test-Path $exe) { # One backup per camera: a second camera's copy must not overwrite the first camera's backup. $leaf = Split-Path -Leaf $InstallDir $previous = Join-Path (Split-Path $InstallDir) $(if ($leaf -eq 'svc') { 'previous' } else { "previous-$leaf" }) if (Test-Path $previous) { Remove-Item $previous -Recurse -Force } New-Item -ItemType Directory -Path $previous -Force | Out-Null Copy-Item (Join-Path $InstallDir '*') $previous -Recurse -Force } $stageRoot = (Get-Item $stage).FullName Get-ChildItem $stage -Recurse -File | ForEach-Object { $rel = $_.FullName.Substring($stageRoot.Length).TrimStart('\', '/') if ($Preserve -contains $rel) { return } $dest = Join-Path $InstallDir $rel New-Item -ItemType Directory -Path (Split-Path $dest) -Force | Out-Null Copy-Item $_.FullName $dest -Force } # The same record the self-updater writes, so its next check orders against THIS release instead # of treating the install-day build as unknown. [ordered]@{ Version = $manifest.version; Commit = $manifest.commit PublishedUtc = $manifest.publishedUtc; InstalledUtc = [DateTimeOffset]::UtcNow.ToString('o') } | ConvertTo-Json | Write-Utf8NoBom $installedRecord Remove-Item $work -Recurse -Force -ErrorAction SilentlyContinue Say " installed to $InstallDir" } } # --- appsettings.Local.json: written once, never rewritten --------------------------------------------- Step "Head configuration ($InstallDir\appsettings.Local.json)" $localPath = Join-Path $InstallDir 'appsettings.Local.json' if (Test-Path $localPath) { Say " exists - left exactly as it is (camera login, capture folder, location and account link are yours)." } else { $askable = -not $NonInteractive -and -not $DryRun if (-not $CameraHost -and $askable) { $CameraHost = Read-Host " Camera IP address or host name (leave blank to fill it in on the dashboard later)" } if ($CameraHost -and $askable) { $u = Read-Host " Camera RTSP user [$CameraUser]" if ($u) { $CameraUser = $u } if ($null -eq $CameraPassword) { $CameraPassword = Read-Host " Camera RTSP password" -AsSecureString } } if ($CameraHost -and $askable -and -not $CameraBrand) { $b = Read-Host " Camera make: [1] Reolink [2] Dahua / EmpireTech / Amcrest / Lorex [3] Hikvision / ANNKE [4] Other [1]" $CameraBrand = switch -Regex ($b) { '^2' { 'Dahua' } '^3' { 'Hikvision' } '^4' { 'Other' } default { 'Reolink' } } } # Where each make serves its two streams; Reolink's are the service's defaults, so nothing is written for it. $subPath = $null; $mainPath = $null switch ($CameraBrand) { 'Dahua' { $subPath = 'cam/realmonitor?channel=1&subtype=1'; $mainPath = 'cam/realmonitor?channel=1&subtype=0' } 'Hikvision' { $subPath = 'Streaming/Channels/102'; $mainPath = 'Streaming/Channels/101' } 'Other' { if ($askable) { $subPath = Read-Host " RTSP path of the camera's SUB (low-resolution) stream, the part after host:554/" $mainPath = Read-Host " RTSP path of its MAIN (full-resolution) stream" } if (-not $subPath -or -not $mainPath) { $subPath = $null; $mainPath = $null; Say " (stream paths left for the dashboard's Camera panel)" } } } if ($askable -and -not $PSBoundParameters.ContainsKey('CapturesDir')) { $d = Read-Host " Capture folder (needs tens of GB; pick a drive with room) [$CapturesDir]" if ($d) { $CapturesDir = $d } } if ($askable -and -not $PSBoundParameters.ContainsKey('Schedule')) { $s = Read-Host " Schedule: Night (only after dark; needs the location you set later) or Always [$Schedule]" if ($s -match '^always$') { $Schedule = 'Always' } elseif ($s -match '^night$') { $Schedule = 'Night' } } $plainPassword = '' if ($CameraPassword -is [securestring]) { $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($CameraPassword) try { $plainPassword = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } elseif ($null -ne $CameraPassword) { Write-Warning "The camera password was passed as plain text; it is now in this shell's history. Prefer -CameraPassword (Read-Host -AsSecureString)." $plainPassword = [string]$CameraPassword } $camera = [ordered]@{} if ($CameraHost) { $camera['Host'] = $CameraHost; $camera['Username'] = $CameraUser; $camera['Password'] = $plainPassword } if ($subPath) { $camera['SubStreamPath'] = $subPath; $camera['MainStreamPath'] = $mainPath } if ($ffmpeg) { $camera['FfmpegPath'] = $ffmpeg; $camera['FfprobePath'] = $ffprobe } $local = [ordered]@{ '//' = 'Written by install-capture-head.ps1 on first install. This file is yours: the dashboard edits it, updates never touch it.' 'Camera' = $camera 'Storage' = [ordered]@{ 'OutputDirectory' = $CapturesDir } 'Schedule' = [ordered]@{ 'Mode' = $Schedule } } if ($AddCamera) { # Its own ports and service name (the updater restarts THIS service, not camera 1's), and what the two # cameras share because they are at one house on one account: the account link and the location. Aim, # lens, ignore zones, sharing and publishing are per camera and are NOT copied. $local['Urls'] = "http://+:$httpPort" $local['RemoteAccess'] = [ordered]@{ 'HttpsPort' = $httpsPort } $local['Update'] = [ordered]@{ 'ServiceName' = $ServiceName } $fc = $first.Config if ($fc.Cloud.ApiKey) { $baseName = if ($fc.Cloud.DeviceName) { [string]$fc.Cloud.DeviceName } else { $env:COMPUTERNAME } $local['Cloud'] = [ordered]@{ 'ApiBaseUrl' = $fc.Cloud.ApiBaseUrl; 'ApiKey' = $fc.Cloud.ApiKey 'AccountId' = $fc.Cloud.AccountId; 'AccountName' = $fc.Cloud.AccountName 'DeviceName' = "$baseName - $CameraName" } } if ($null -ne $fc.Mount.Latitude -and $null -ne $fc.Mount.Longitude) { $mount = [ordered]@{ 'Latitude' = $fc.Mount.Latitude; 'Longitude' = $fc.Mount.Longitude } if ($null -ne $fc.Mount.AltitudeMeters) { $mount['AltitudeMeters'] = $fc.Mount.AltitudeMeters } if ($fc.Mount.NetworkFingerprint) { $mount['NetworkFingerprint'] = $fc.Mount.NetworkFingerprint } $local['Mount'] = $mount } } $json = $local | ConvertTo-Json -Depth 5 $shown = $json if ($plainPassword) { $shown = $shown.Replace($plainPassword, '********') } if ($local['Cloud'] -and $local['Cloud']['ApiKey']) { $shown = $shown.Replace([string]$local['Cloud']['ApiKey'], '********') } Plan "write (secrets hidden here):`n$shown" if (-not $DryRun) { New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null New-Item -ItemType Directory -Path $CapturesDir -Force | Out-Null Write-Utf8NoBom $localPath $json # The file holds the camera password: administrators and the service account only. try { & icacls $localPath /inheritance:r /grant:r 'SYSTEM:F' 'Administrators:F' | Out-Null } catch { Write-Warning "Could not tighten permissions on $localPath; it is readable by other users of this PC." } Say " written" } } # --- register or start the service ------------------------------------------------------------------- Step "Start" $installScript = Join-Path $InstallDir 'install-service.ps1' if (-not $svc) { Plan "run $installScript -InstallDir $InstallDir (New-Service auto-start + sc failure restart/5s, firewall rules for TCP $(([Uri]$DashboardUrl).Port) private + $(if ($AddCamera) { $httpsPort } else { 5443 }), desktop and Start Menu shortcut), which also starts it" if (-not $DryRun) { if (-not (Test-Path $installScript)) { Fail "$installScript is not in the package." } if ($AddCamera) { & $installScript -InstallDir $InstallDir -ServiceName $ServiceName -Url $DashboardUrl ` -DisplayName "UAP Finder $CameraName" -HttpsPort $httpsPort } else { & $installScript -InstallDir $InstallDir -ServiceName $ServiceName -Url $DashboardUrl } } } else { if ((Get-Service $ServiceName).Status -eq 'Running' -and -not $needsFiles) { Say " already running on the current release." } else { Plan "start $ServiceName" if (-not $DryRun) { Start-Service $ServiceName } } } if (-not $DryRun) { $health = "$DashboardUrl/health" $ok = $false $deadline = (Get-Date).AddSeconds(90) while ((Get-Date) -lt $deadline) { try { $r = Invoke-RestMethod -Uri $health -TimeoutSec 5 -UseBasicParsing; if ($r) { $ok = $true; break } } catch { } Start-Sleep -Seconds 3 } if ($ok) { Say " $health answers - the head is up." } else { Write-Warning "The service started but $health did not answer within 90 s. Check the log under $CapturesDir\logs." } } # --- link the new camera with the others (ADR 0094) ------------------------------------------------------ # One request to the NEW camera links both sides: it asks each existing camera to add it back and only saves # its own side once that worked. The same path the dashboard's "Cameras on the same sky" panel uses. $linked = @() if ($AddCamera -and -not $NoLink) { Step "Link with the other camera(s)" $link = $true if (-not $NonInteractive -and -not $DryRun) { $a = Read-Host " Is $CameraName pointed at the same patch of sky as your other camera(s)? Link them so both save the same moments [Y/n]" if ($a -match '^n') { $link = $false; Say " Not linked. Link them any time from either dashboard: Settings > Cameras on the same sky." } } if ($link) { foreach ($cam in $installed) { $peerName = if ($cam.RigName) { $cam.RigName } elseif ($cam.Service -eq 'UAPFinderCapture') { 'Camera 1' } else { $null } $body = @{ cameraName = $CameraName; peerUrl = "http://localhost:$($cam.Port)"; peerName = $peerName; linkBack = $true } | ConvertTo-Json Plan "POST $DashboardUrl/api/rig/link $($body -replace '\s+', ' ')" if ($DryRun) { continue } # The existing camera may have just restarted on its update; give it the same 90 s. $deadline = (Get-Date).AddSeconds(90) while ((Get-Date) -lt $deadline) { try { if (Invoke-RestMethod -Uri "http://localhost:$($cam.Port)/health" -TimeoutSec 5 -UseBasicParsing) { break } } catch { } Start-Sleep -Seconds 3 } try { $r = Invoke-RestMethod -Method Post -Uri "$DashboardUrl/api/rig/link" -Body $body -ContentType 'application/json' -TimeoutSec 30 -UseBasicParsing Say " $($r.message)" $linked += $cam } catch { $msg = $_.Exception.Message try { $msg = ($_.ErrorDetails.Message | ConvertFrom-Json).message } catch { } Write-Warning "Could not link with $($cam.Service): $msg" Write-Warning "Link them later from either dashboard: Settings > Cameras on the same sky." } } } } # --- what next ----------------------------------------------------------------------------------------- Write-Host "" Write-Host "Done. What to do next:" -ForegroundColor Green if ($AddCamera) { Say " 1. $CameraName's dashboard: $DashboardUrl (the 'UAP Finder $CameraName' shortcut on the desktop)." Say " Your first camera keeps its own: http://localhost:$($first.Port)." if (-not $CameraHost) { Say " Camera panel: enter the camera's address and login, press Test connection, save." } elseif (-not $plainPassword) { Say " Camera panel: enter the camera's password, press Test connection, save." } if ($local -and $local['Cloud']) { Say " 2. Account and location were copied from the first camera; it shows on uapfinder.com/cameras as its own camera." } else { Say " 2. Account panel: sign in with the same UAP Finder account as your first camera, then set the LOCATION from the phone or website." } Say " 3. On $CameraName's dashboard: calibrate its AIM on the first clear night and paint its IGNORE ZONES. Every camera" Say " needs its own, even pointed the same way. Sharing to the website is off for it until you turn it on there." if ($linked.Count -gt 0) { Say " 4. Linked: Settings > Cameras on the same sky shows the link and the moments both cameras saved." } if (-not $DryRun -and -not $NoBrowser) { try { Start-Process $DashboardUrl } catch { } } return } Say " 1. Dashboard: $DashboardUrl (also the 'UAP Finder Camera' shortcut on the desktop)." if (-not $CameraHost -and -not (Test-Path $localPath)) { Say " Camera panel: enter the camera's address and login, press Test connection, save." } Say " 2. Account panel: sign in with your UAP Finder account. That is the whole pairing step - the phone app" Say " and uapfinder.com/cameras find this camera through it. Signing in shares nothing publicly." Say " 3. Set the camera's LOCATION from the phone app (My Cameras) or uapfinder.com/cameras. Night mode captures" Say " continuously until it knows where the sun is; satellite and aircraft matching need it too." Say " 4. On the dashboard: set the mount AIM (star-calibrate on the first clear night) and paint IGNORE ZONES over" Say " treetops, roof lines and any burned-in clock. Both need the picture in front of you, so they stay there." Say " Running this script again later upgrades the head in place; it also updates itself every 6 hours." if (-not $DryRun -and -not $NoBrowser) { try { Start-Process $DashboardUrl } catch { } }