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,143 @@
package pw.binom.viewmate.glasses
import java.io.ByteArrayOutputStream
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class MdnsClientTest {
private val fallback = "ws://10.0.2.2:8080/ws/glasses"
@Test
fun mdnsHostWins() {
assertEquals(
"ws://192.168.76.7:8080/ws/glasses",
MdnsClient.resolvePhoneAddress("ws://192.168.76.7:8080/ws/glasses", fallback),
"mDNS нашёл телефон — используем его адрес",
)
}
@Test
fun nullHostFallsBack() {
assertEquals(fallback, MdnsClient.resolvePhoneAddress(null, fallback), "mDNS ничего не нашёл — fallback")
}
@Test
fun blankHostFallsBack() {
assertEquals(fallback, MdnsClient.resolvePhoneAddress("", fallback), "пустой адрес — fallback")
assertEquals(fallback, MdnsClient.resolvePhoneAddress(" ", fallback), "пробельный адрес — fallback")
}
@Test
fun emptyFallbackStaysEmpty() {
assertEquals("", MdnsClient.resolvePhoneAddress(null, ""), "пустой fallback не ломается")
}
@Test
fun emulatorTimeoutShort() {
assertEquals(3_000L, MdnsClient.mdnsTimeoutMs(isEmulator = true), "эмулятор: mDNS нет — быстрый fallback")
}
@Test
fun deviceTimeoutLong() {
assertEquals(15_000L, MdnsClient.mdnsTimeoutMs(isEmulator = false), "железо: mDNS RayNeo медленный — ждём 15с")
}
@Test
fun parsesPtrSrvA() {
// Синтетический mDNS-ответ: PTR + SRV + A → IP телефона.
assertEquals("192.168.76.121", MdnsClient.parseMdnsResponse(buildPtrSrvAPacket()))
}
@Test
fun acceptsAnswerInAdditionalSection() {
// A-запись в additional-секции (ARCOUNT) — так отвечают настоящие mDNS-ресолверы.
val packet = buildPtrSrvAPacket(ipInAdditional = true)
assertEquals("192.168.76.121", MdnsClient.parseMdnsResponse(packet))
}
@Test
fun garbageReturnsNull() {
assertNull(MdnsClient.parseMdnsResponse(ByteArray(0)), "пустой пакет — null")
assertNull(MdnsClient.parseMdnsResponse(byteArrayOf(1, 2, 3)), "короткий мусор — null")
}
@Test
fun foreignMulticastNoiseRejected() {
// Чужой сервис (не viewmate): A-запись есть, но PTR-имя другое.
val packet = buildForeignServicePacket()
assertNull(MdnsClient.parseMdnsResponse(packet), "чужой multicast-мусор — null")
}
private fun buildPtrSrvAPacket(ipInAdditional: Boolean = false): ByteArray {
val questionName = name("_viewmate", "_tcp", "local")
val question = questionName + u16(12) + u16(1)
val ptrRdata = name("viewmate", "_viewmate", "_tcp", "local")
val ptrAnswer = ptr(12) + u16(12) + u16(1) + u16ttl(120) + u16(ptrRdata.size) + ptrRdata
val ptrRdataOffset = 12 + question.size + ptrAnswer.size - ptrRdata.size
val targetName = name("honor", "local")
val srvRdata = u16(0) + u16(0) + u16(8080) + targetName
val srvAnswer = ptr(ptrRdataOffset) + u16(33) + u16(1) + u16ttl(120) + u16(srvRdata.size) + srvRdata
val srvRdataOffset = 12 + question.size + ptrAnswer.size + srvAnswer.size - srvRdata.size
val targetOffset = srvRdataOffset + 6
val aRecord = ptr(targetOffset) + u16(1) + u16(1) + u16ttl(120) + u16(4) + ip4(192, 168, 76, 121)
val ancount = if (ipInAdditional) 2 else 3
val arcount = if (ipInAdditional) 1 else 0
val header = u16(0) + u16(0x8400) + u16(1) + u16(ancount) + u16(0) + u16(arcount)
return header + question + ptrAnswer + srvAnswer + aRecord
}
private fun buildForeignServicePacket(): ByteArray {
val questionName = name("_googlecast", "_tcp", "local")
val question = questionName + u16(12) + u16(1)
val ptrRdata = name("chromecast", "_googlecast", "_tcp", "local")
val ptrAnswer = ptr(12) + u16(12) + u16(1) + u16ttl(120) + u16(ptrRdata.size) + ptrRdata
val ptrRdataOffset = 12 + question.size + ptrAnswer.size - ptrRdata.size
val targetName = name("kitchen", "local")
val srvRdata = u16(0) + u16(0) + u16(8009) + targetName
val srvAnswer = ptr(ptrRdataOffset) + u16(33) + u16(1) + u16ttl(120) + u16(srvRdata.size) + srvRdata
val srvRdataOffset = 12 + question.size + ptrAnswer.size + srvAnswer.size - srvRdata.size
val targetOffset = srvRdataOffset + 6
val aAnswer = ptr(targetOffset) + u16(1) + u16(1) + u16ttl(120) + u16(4) + ip4(192, 168, 1, 50)
val header = u16(0) + u16(0x8400) + u16(1) + u16(3) + u16(0) + u16(0)
return header + question + ptrAnswer + srvAnswer + aAnswer
}
private fun u16(v: Int): ByteArray =
byteArrayOf(((v shr 8) and 0xFF).toByte(), (v and 0xFF).toByte())
private fun ip4(a: Int, b: Int, c: Int, d: Int): ByteArray =
byteArrayOf(a.toByte(), b.toByte(), c.toByte(), d.toByte())
private fun u16ttl(v: Int): ByteArray =
byteArrayOf(0, 0, ((v shr 8) and 0xFF).toByte(), (v and 0xFF).toByte())
private fun label(s: String): ByteArray =
byteArrayOf(s.length.toByte()) + s.toByteArray(Charsets.US_ASCII)
private fun name(vararg labels: String): ByteArray {
val out = ByteArrayOutputStream()
for (l in labels) out.write(label(l))
out.write(0)
return out.toByteArray()
}
private fun ptr(offset: Int): ByteArray =
byteArrayOf((0xC0 or ((offset shr 8) and 0x3F)).toByte(), (offset and 0xFF).toByte())
private operator fun ByteArray.plus(other: ByteArray): ByteArray {
val out = ByteArray(size + other.size)
System.arraycopy(this, 0, out, 0, size)
System.arraycopy(other, 0, out, size, other.size)
return out
}
}
@@ -0,0 +1,27 @@
package pw.binom.viewmate.glasses
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class PhoneScanTest {
@Test
fun subnetRangeBuildsDot24() {
val range = PhoneScan.subnetRange("192.168.76.152")
assertEquals(254, range.size, "в /24 — 254 хоста")
assertEquals("192.168.76.1", range.first(), "сеть начинается с .1")
assertEquals("192.168.76.254", range.last(), "сеть заканчивается на .254")
assertEquals("192.168.76.121", range[120], "телефон (в задаче 192.168.76.121) внутри подсети")
assertTrue(range.none { it.endsWith(".0") || it.endsWith(".255") }, "без сетевого и бродкаст-адреса")
}
@Test
fun subnetRangeRejectsInvalid() {
assertTrue(PhoneScan.subnetRange("").isEmpty(), "пустая строка")
assertTrue(PhoneScan.subnetRange("не-адрес").isEmpty(), "не адрес")
assertTrue(PhoneScan.subnetRange("192.168.76").isEmpty(), "мало октетов")
assertTrue(PhoneScan.subnetRange("256.1.1.1").isEmpty(), "октет вне 0..255")
assertTrue(PhoneScan.subnetRange("192.168.76.152.5").isEmpty(), "много октетов")
}
}