package pw.binom.viewmate.glasses import java.io.InputStream import java.net.InetSocketAddress import java.net.Socket import java.security.SecureRandom import java.util.Base64 import kotlin.math.min import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext /** * План Б для очков: если mDNS-мультикаст на устройстве мёртв (RayNeo), * телефон ищется прямым сканированием своей /24 подсети. На каждом адресе — * TCP-коннект к :8080, при открытом порту — короткий WS-хендшейк `/ws/glasses`: * наш Ktor-сервер телефона отвечает `HTTP/1.1 101 Switching Protocols`. */ object PhoneScan { const val PORT = 8080 const val WS_PATH = "/ws/glasses" /** Таймаут TCP-подключения к одному хосту (мёртвый адрес не должен висеть). */ const val CONNECT_TIMEOUT_MS = 300 /** Таймаут чтения ответа после подключения. */ const val READ_TIMEOUT_MS = 500 /** Сколько хостов пробуем параллельно (слотов, как 32–64 в задаче). */ const val CONCURRENCY = 48 /** Общий таймаут скан-цикла. */ const val SCAN_TIMEOUT_MS = 5_000L private val random = SecureRandom() /** * Чистая функция (для юнит-тестов): все адреса /24 подсети для [ip] * (192.168.76.152 → 192.168.76.1..192.168.76.254). Битый адрес → пустой список. */ fun subnetRange(ip: String): List { val parts = ip.split(".") if (parts.size != 4) return emptyList() val octets = parts.map { it.toIntOrNull() } if (octets.any { it == null || it !in 0..255 }) return emptyList() val (a, b, c) = octets.map { it!! } return (1..254).map { "$a.$b.$c.$it" } } /** * Просканировать /24 подсеть [localIp] и вернуть ws-URL телефона * (`ws://:8080/ws/glasses`) либо null. Параллельно, батчами по * [CONCURRENCY], с общим дедлайном [timeoutMs]. */ suspend fun findPhoneWsUrl(localIp: String, timeoutMs: Long = SCAN_TIMEOUT_MS): String? = withContext(Dispatchers.IO) { val addresses = subnetRange(localIp) if (addresses.isEmpty()) { log("scan", "scan: некорректный адрес $localIp — нечего сканировать") return@withContext null } val network = addresses.first().substringBeforeLast('.') log("scan", "scan: сканирую $network.1..254:$PORT (${addresses.size} хостов)") val deadline = System.currentTimeMillis() + timeoutMs val semaphore = Semaphore(CONCURRENCY) var index = 0 while (index < addresses.size) { if (System.currentTimeMillis() >= deadline) break val end = min(index + CONCURRENCY, addresses.size) val batch = addresses.subList(index, end).map { candidate -> async { semaphore.withPermit { if (System.currentTimeMillis() >= deadline) null else if (isGlassesServer(candidate)) candidate else null } } } index = end for (deferred in batch) { val hit = deferred.await() if (hit != null) { log("scan", "scan: найден телефон $hit:$PORT") return@withContext "ws://$hit:$PORT$WS_PATH" } } } log("scan", "scan: телефон не найден за ${timeoutMs}мс") null } /** Наш ли это сервер на [ip]: TCP-коннект к :8080 + WS-хендшейк `/ws/glasses`. */ private fun isGlassesServer(ip: String): Boolean { val socket = Socket() return try { socket.connect(InetSocketAddress(ip, PORT), CONNECT_TIMEOUT_MS) socket.soTimeout = READ_TIMEOUT_MS val request = buildHandshake(ip) socket.getOutputStream().write(request.toByteArray(Charsets.US_ASCII)) socket.getOutputStream().flush() readStatusLine(socket.getInputStream()) } catch (e: Exception) { false } finally { runCatching { socket.close() } } } private fun buildHandshake(ip: String): String { val keyBytes = ByteArray(16) random.nextBytes(keyBytes) val key = Base64.getEncoder().encodeToString(keyBytes) return "GET $WS_PATH HTTP/1.1\r\n" + "Host: $ip:$PORT\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Key: $key\r\n" + "Sec-WebSocket-Version: 13\r\n" + "\r\n" } /** Прочитать строку статуса (`HTTP/1.1 101 ...` или `... 200 ...`) с учётом частичных чтений. */ private fun readStatusLine(input: InputStream): Boolean { val buf = ByteArray(256) var n = 0 while (n < buf.size) { val read = input.read(buf, n, buf.size - n) if (read < 0) break n += read val text = String(buf, 0, n, Charsets.US_ASCII) val lineEnd = text.indexOf("\r\n") if (lineEnd >= 0) { val status = text.substring(0, lineEnd) return status.startsWith("HTTP/1.1 101") || status.startsWith("HTTP/1.1 200") } } return false } }