workflows/default/systems/ui/modules/AetherAPI.psm1

<#
.SYNOPSIS
Aether (Hue bridge) discovery and configuration API module

.DESCRIPTION
Provides SSDP/mDNS bridge discovery and aether config CRUD.
Extracted from server.ps1 for modularity.
#>


$script:Config = @{
    ControlDir = $null
    LastConduit = $null
    LastConduitTime = $null
}

function Initialize-AetherAPI {
    param(
        [Parameter(Mandatory)] [string]$ControlDir
    )
    $script:Config.ControlDir = $ControlDir
}

function Find-Conduit {
    $controlDir = $script:Config.ControlDir

    # Check in-memory cache first (avoids re-scanning within 5 minutes)
    if ($script:Config.LastConduit -and $script:Config.LastConduitTime) {
        $age = (Get-Date) - $script:Config.LastConduitTime
        if ($age.TotalMinutes -lt 5) {
            return $script:Config.LastConduit
        }
    }

    # Method 0: Try last known IP from cached config (fastest)
    $configFile = Join-Path $controlDir "aether-config.json"
    if (Test-Path $configFile) {
        try {
            $cachedConfig = Get-Content $configFile -Raw | ConvertFrom-Json
            if ($cachedConfig.conduit) {
                $response = Invoke-RestMethod -Uri "https://$($cachedConfig.conduit)/api/config" -TimeoutSec 2 -SkipCertificateCheck -ErrorAction Stop
                if ($response.bridgeid) {
                    $result = @{ IP = $cachedConfig.conduit; Id = $response.bridgeid }
                    $script:Config.LastConduit = $result
                    $script:Config.LastConduitTime = Get-Date
                    return $result
                }
            }
        } catch {
            # Cached IP no longer valid, continue with discovery
        }
    }

    # Method 1: Try Philips discovery endpoint (meethue.com)
    try {
        $discoveryResponse = Invoke-RestMethod -Uri "https://discovery.meethue.com/" -TimeoutSec 5 -ErrorAction Stop
        if ($discoveryResponse -and $discoveryResponse.Count -gt 0) {
            $result = @{ IP = $discoveryResponse[0].internalipaddress; Id = $discoveryResponse[0].id }
            $script:Config.LastConduit = $result
            $script:Config.LastConduitTime = Get-Date
            return $result
        }
    } catch {
        # Discovery endpoint failed, try SSDP
    }

    # Method 2: SSDP multicast discovery
    try {
        $ssdpMessage = @"
M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
MX: 3
ST: urn:schemas-upnp-org:device:basic:1

"@

        $udpClient = New-Object System.Net.Sockets.UdpClient
        $udpClient.Client.ReceiveTimeout = 3000
        $udpClient.Client.SetSocketOption([System.Net.Sockets.SocketOptionLevel]::Socket, [System.Net.Sockets.SocketOptionName]::ReuseAddress, $true)

        $groupEndpoint = New-Object System.Net.IPEndPoint ([System.Net.IPAddress]::Parse("239.255.255.250")), 1900
        $bytes = [System.Text.Encoding]::ASCII.GetBytes($ssdpMessage)
        $udpClient.Send($bytes, $bytes.Length, $groupEndpoint) | Out-Null

        $remoteEndpoint = New-Object System.Net.IPEndPoint ([System.Net.IPAddress]::Any), 0

        # Collect responses for up to 3 seconds
        $deadline = (Get-Date).AddSeconds(3)
        while ((Get-Date) -lt $deadline) {
            try {
                $receiveBytes = $udpClient.Receive([ref]$remoteEndpoint)
                $response = [System.Text.Encoding]::ASCII.GetString($receiveBytes)

                # Look for bridge identifier in response
                if ($response -match "IpBridge|hue-bridgeid") {
                    $ip = $remoteEndpoint.Address.ToString()

                    # Extract bridge ID from response if available
                    $bridgeId = ""
                    if ($response -match "hue-bridgeid:\s*([A-F0-9]+)") {
                        $bridgeId = $matches[1]
                    }

                    $udpClient.Close()
                    $result = @{ IP = $ip; Id = $bridgeId }
                    $script:Config.LastConduit = $result
                    $script:Config.LastConduitTime = Get-Date
                    return $result
                }
            } catch [System.Net.Sockets.SocketException] {
                # Timeout - no more responses
                break
            }
        }

        $udpClient.Close()
    } catch {
        # SSDP failed
    }

    # Method 3: Subnet scan on port 443 — new bridges disable SSDP
    try {
        # Use a UDP socket to let the OS pick the outbound interface — works cross-platform,
        # avoids Docker/VPN/loopback, and requires no actual network traffic.
        $localIp = $null
        $udpSocket = [System.Net.Sockets.Socket]::new(
            [System.Net.Sockets.AddressFamily]::InterNetwork,
            [System.Net.Sockets.SocketType]::Dgram,
            [System.Net.Sockets.ProtocolType]::Udp
        )
        try {
            $udpSocket.Connect('8.8.8.8', 80)
            $localIp = ($udpSocket.LocalEndPoint -as [System.Net.IPEndPoint]).Address.ToString()
        } catch {
            # No default route — fall back to interface enumeration below.
        } finally {
            $udpSocket.Dispose()
        }

        if (-not $localIp) {
            $localIp = [System.Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces() |
                Where-Object { $_.OperationalStatus -eq 'Up' -and $_.NetworkInterfaceType -ne 'Loopback' } |
                ForEach-Object { $_.GetIPProperties().UnicastAddresses } |
                Where-Object { $_.Address.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork } |
                Select-Object -First 1 -ExpandProperty Address |
                ForEach-Object { $_.ToString() }
        }

        if ($localIp -match "(\d+\.\d+\.\d+)\.\d+") {
            $subnet = $matches[1]
            $tasks = @{}
            1..254 | ForEach-Object {
                $ip = "$subnet.$_"
                $tcp = New-Object System.Net.Sockets.TcpClient
                $tasks[$ip] = @{ Task = $tcp.ConnectAsync($ip, 443); Client = $tcp }
            }
            Start-Sleep -Seconds 2
            foreach ($kv in $tasks.GetEnumerator()) {
                $connected = $kv.Value.Task.Status -eq "RanToCompletion"
                try { $kv.Value.Client.Dispose() } catch { Write-BotLog -Level Warn -Message "Task operation failed" -Exception $_ }
                if ($connected) {
                    try {
                        $response = Invoke-RestMethod -Uri "https://$($kv.Key)/api/config" -SkipCertificateCheck -TimeoutSec 2 -ErrorAction Stop
                        if ($response.bridgeid) {
                            $result = @{ IP = $kv.Key; Id = $response.bridgeid }
                            $script:Config.LastConduit = $result
                            $script:Config.LastConduitTime = Get-Date
                            return $result
                        }
                    } catch {
                        # Not a Hue bridge
                    }
                }
            }
        }
    } catch {
        # Subnet scan failed
    }

    return $null
}

function Get-AetherScanResult {
    $conduit = Find-Conduit
    if ($conduit) {
        return @{
            found = $true
            conduit = $conduit.IP
            id = $conduit.Id
        }
    } else {
        return @{
            found = $false
            conduit = $null
            id = $null
        }
    }
}

function Get-AetherConfig {
    $configFile = Join-Path $script:Config.ControlDir "aether-config.json"

    if (Test-Path $configFile) {
        try {
            return Get-Content $configFile -Raw | ConvertFrom-Json
        } catch {
            return @{ linked = $false }
        }
    } else {
        return @{ linked = $false }
    }
}

function Set-AetherConfig {
    param(
        [Parameter(Mandatory)] [string]$Body
    )
    $controlDir = $script:Config.ControlDir
    $configFile = Join-Path $controlDir "aether-config.json"

    $config = $Body | ConvertFrom-Json
    $config | ConvertTo-Json -Depth 5 | Set-Content $configFile -Force

    # Log bond result with details
    if ($config.linked) {
        $nodeCount = if ($config.nodes) { $config.nodes.Count } else { 0 }
        Write-Status "Aether bonded to $($config.conduit) with $nodeCount node(s)" -Type Success
    } else {
        Write-Status "Aether unlinked" -Type Warn
    }

    return @{
        success = $true
        config = $config
    }
}

function Invoke-ConduitBond {
    param(
        [Parameter(Mandatory)] [string]$IP
    )
    try {
        $body = @{ devicetype = "dotbot#aether" } | ConvertTo-Json -Compress
        $response = Invoke-RestMethod -Uri "https://$IP/api" -Method Post -Body $body -ContentType "application/json" -SkipCertificateCheck -TimeoutSec 5 -ErrorAction Stop
        if ($response -is [array] -and $response[0].success) {
            return @{ success = $true; username = $response[0].success.username }
        }
        # Button not pressed yet or other error
        $errorType = if ($response -is [array] -and $response[0].error) { $response[0].error.type } else { "unknown" }
        $errorDesc = if ($response -is [array] -and $response[0].error) { $response[0].error.description } else { "Unknown error" }
        return @{ success = $false; error = $errorType; description = $errorDesc }
    } catch {
        return @{ success = $false; error = "connection"; description = $_.Exception.Message }
    }
}

function Get-ConduitNodes {
    param(
        [Parameter(Mandatory)] [string]$IP,
        [Parameter(Mandatory)] [string]$Token
    )
    try {
        $response = Invoke-RestMethod -Uri "https://$IP/api/$Token/lights" -SkipCertificateCheck -TimeoutSec 5 -ErrorAction Stop
        $nodes = @()
        foreach ($prop in $response.PSObject.Properties) {
            $light = $prop.Value
            $nodes += @{
                id = $prop.Name
                name = $light.name
                type = $light.type
                reachable = $light.state.reachable
            }
        }
        return @{ success = $true; nodes = $nodes }
    } catch {
        return @{ success = $false; nodes = @(); error = $_.Exception.Message }
    }
}

function Test-ConduitLink {
    param(
        [Parameter(Mandatory)] [string]$IP,
        [Parameter(Mandatory)] [string]$Token
    )
    try {
        $null = Invoke-RestMethod -Uri "https://$IP/api/$Token/lights" -SkipCertificateCheck -TimeoutSec 3 -ErrorAction Stop
        return @{ valid = $true }
    } catch {
        return @{ valid = $false }
    }
}

function Invoke-ConduitCommand {
    param(
        [Parameter(Mandatory)] [string]$IP,
        [Parameter(Mandatory)] [string]$Token,
        [Parameter(Mandatory)] [array]$Nodes,
        [Parameter(Mandatory)] [string]$State
    )
    $results = @()
    foreach ($nodeId in $Nodes) {
        try {
            $response = Invoke-RestMethod -Uri "https://$IP/api/$Token/lights/$nodeId/state" -Method Put -Body $State -ContentType "application/json" -SkipCertificateCheck -TimeoutSec 3 -ErrorAction Stop
            $results += @{ nodeId = $nodeId; success = $true }
        } catch {
            $results += @{ nodeId = $nodeId; success = $false; error = $_.Exception.Message }
        }
    }
    return @{ success = $true; results = $results }
}

Export-ModuleMember -Function @('Initialize-AetherAPI', 'Find-Conduit', 'Get-AetherScanResult', 'Get-AetherConfig', 'Set-AetherConfig', 'Invoke-ConduitBond', 'Get-ConduitNodes', 'Test-ConduitLink', 'Invoke-ConduitCommand')