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,73 @@
package pw.binom.viewmate.phone
import android.content.Context
import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo
/**
* Публикация WS-сервера телефона через mDNS (NSD): очки находят телефон
* авто-обнаружением по `_viewmate._tcp`. Регистрация/разрегистрация
* обёрнуты в try/catch — NSD не должен валить приложение (на эмуляторе
* mDNS может отсутствовать, на Android 13+ registerService может требовать
* NEARBY_WIFI_DEVICES и кидать SecurityException).
*/
class NsdPublisher(context: Context) {
private val nsdManager: NsdManager? =
runCatching {
context.applicationContext.getSystemService(Context.NSD_SERVICE) as NsdManager
}.getOrNull()
private val registrationListener = object : NsdManager.RegistrationListener {
override fun onServiceRegistered(info: NsdServiceInfo) {
log("nsd", "NSD: сервис опубликован ${info.serviceType}:${info.port}")
}
override fun onRegistrationFailed(info: NsdServiceInfo, errorCode: Int) {
log("nsd", "NSD: публикация не удалась: код $errorCode")
}
override fun onServiceUnregistered(info: NsdServiceInfo) {
log("nsd", "NSD: сервис снят с публикации")
}
override fun onUnregistrationFailed(info: NsdServiceInfo, errorCode: Int) {
log("nsd", "NSD: снятие с публикации не удалось: код $errorCode")
}
}
private var registered = false
/** Опубликовать сервис `_viewmate._tcp:[port]` (не должен бросать). */
fun publish(serviceName: String = "viewmate", serviceType: String = "_viewmate._tcp", port: Int = PhoneConfig.SERVER_PORT) {
val nsdManager = nsdManager ?: run {
log("nsd", "NSD: NsdManager недоступен — публикация пропущена")
return
}
if (registered) return
val serviceInfo = NsdServiceInfo().apply {
this.serviceName = serviceName
this.serviceType = serviceType
this.port = port
}
try {
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListener)
registered = true
log("nsd", "NSD: публикация $serviceType:$port запущена ($serviceName)")
} catch (e: Exception) {
log("nsd", "NSD: публикация не удалась: ${e.message}")
}
}
/** Снять сервис с публикации (при остановке сервера / завершении приложения). */
fun unpublish() {
val nsdManager = nsdManager ?: return
if (!registered) return
try {
nsdManager.unregisterService(registrationListener)
registered = false
} catch (e: Exception) {
log("nsd", "NSD: снятие с публикации не удалось: ${e.message}")
}
}
}
@@ -25,6 +25,9 @@ class PhoneApp : Application() {
/** WS-сервер для очков. */
val server: GlassesServer = GlassesServer(port = PhoneConfig.SERVER_PORT, state = state)
/** Публикация сервера в локальной сети (mDNS `_viewmate._tcp`) для авто-обнаружения очками. */
val nsd: NsdPublisher by lazy { NsdPublisher(this) }
/** HTTP-клиенты бэкендов (Jellyfin-каталог, media-mirror). */
val jellyfin: JellyfinClient = JellyfinClient(PhoneConfig.JELLYFIN_URL, PhoneConfig.JELLYFIN_API_KEY)
val mirror: MirrorClient = MirrorClient(PhoneConfig.MIRROR_URL, PhoneConfig.MIRROR_API_KEY)
@@ -53,6 +56,7 @@ class PhoneApp : Application() {
server.start()
log("server", "WS-сервер запущен на ${PhoneConfig.SERVER_PORT}")
nsd.publish()
// каждый PlaybackPosition от очков (5с) → синхронизация аудио-плеера
server.hub.onPlaybackPosition = { positionMs, playing -> audioSync.sync(positionMs, playing) }
@@ -100,6 +104,12 @@ class PhoneApp : Application() {
}
}
override fun onTerminate() {
nsd.unpublish()
server.stop()
super.onTerminate()
}
companion object {
lateinit var instance: PhoneApp
private set