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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user