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, статусы идут
This commit is contained in:
Hermes Agent
2026-08-19 20:06:27 +03:00
parent 35f462b34a
commit 698b6cf6dd
11 changed files with 880 additions and 32 deletions
@@ -0,0 +1,141 @@
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
}
}