Files
view-mate/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/PhoneScan.kt
T
Hermes Agent 698b6cf6dd feat(app-glasses): авто-обнаружение телефона — mDNS-клиент + скан /24 (RayNeo mDNS мёртв)
- MdnsClient: свой mDNS (UDP 5353, PTR _viewmate._tcp, парсер со сжатием 0xC0), join на всех Wi-Fi-интерфейсах, MulticastLock
- PhoneScan: план Б — TCP-скан своей /24 :8080 батчами, детект по WS-handshake (101), таймаут 5с
- HostConnection: цепочка mDNS → scan /24 (пропуск на эмуляторе) → fallback 10.0.2.2
- app-phone: NsdPublisher (_viewmate._tcp, порт 8080) + NEARBY_WIFI_DEVICES
- Тесты 27/0 glasses (MdnsClientTest, PhoneScanTest), 67/0 phone
- Железо (RayNeo X2 + HONOR): mDNS multicast режется WiFi-стеком очков («ни одного пакета за 15с»), скан /24 нашёл телефон за 1.2с, ws://192.168.76.121:8080, hello→welcome, статусы идут
2026-08-19 20:06:27 +03:00

142 lines
6.0 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String> {
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://<ip>: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
}
}