feat: WS-протокол очки↔телефон + app-host (JVM Ktor-сервер)
lib-core (commonMain): - Messages.kt: GlassesToHost (hello, gesture, glasses_status, playback_position, glasses_off) - HostToGlasses.kt: Welcome, SetMode, MediaCommandMsg, PlayVideo, AssistantStateMsg, ShowText, ChatSessionMsg - AssistantState enum (IDLE/LISTENING/THINKING/SPEAKING) - round-trip тесты сериализации обоих направлений app-host (переведён с Android на JVM): - Ktor WS-сервер 0.0.0.0:8080/ws/glasses — очки подключаются как WS-клиенты - GlassesHub: broadcast всем очкам, счётчик подключений (StateFlow) - консольный ввод: play/mode/state/text/session/cmd/exit — команды от «телефона» - логирование всех сообщений обоих направлений - HostServerTest: hello→welcome, счётчик подключений Проверено: сборка зелёная, 21 тест 0 провалов, живой WS-цикл (hello→welcome, gesture, статус, позиция, glasses_off)
This commit is contained in:
@@ -2,3 +2,4 @@
|
||||
build/
|
||||
local.properties
|
||||
*.hprof
|
||||
local.properties
|
||||
|
||||
+23
-42
@@ -1,50 +1,31 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
application
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "pw.binom.viewmate.host"
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "pw.binom.viewmate.host"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
}
|
||||
kotlin { jvmToolchain(17) }
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.appcompat:appcompat:1.3.0")
|
||||
implementation("androidx.core:core-ktx:1.7.0")
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.android.material3)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(project(":lib-core"))
|
||||
implementation("io.ktor:ktor-server-core:3.5.2")
|
||||
implementation("io.ktor:ktor-server-netty:3.5.2")
|
||||
implementation("io.ktor:ktor-server-websockets:3.5.2")
|
||||
implementation("io.ktor:ktor-server-content-negotiation:3.5.2")
|
||||
implementation("io.ktor:ktor-serialization-kotlinx-json:3.5.2")
|
||||
implementation("org.slf4j:slf4j-simple:2.0.17")
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation("io.ktor:ktor-server-test-host:3.5.2")
|
||||
testImplementation("io.ktor:ktor-client-core:3.5.2")
|
||||
testImplementation("io.ktor:ktor-client-websockets:3.5.2")
|
||||
testImplementation("io.ktor:ktor-client-cio:3.5.2")
|
||||
}
|
||||
|
||||
application { mainClass.set("pw.binom.viewmate.host.MainKt") }
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
|
||||
<application
|
||||
android:label="ViewMate Host"
|
||||
android:theme="@style/Theme.AppCompat"
|
||||
android:allowBackup="true">
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,106 @@
|
||||
package pw.binom.viewmate.host
|
||||
|
||||
import io.ktor.server.websocket.DefaultWebSocketServerSession
|
||||
import io.ktor.websocket.send
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.protocol.GlassesOff
|
||||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
import pw.binom.viewmate.core.protocol.Gesture
|
||||
import pw.binom.viewmate.core.protocol.Hello
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
import pw.binom.viewmate.core.protocol.PlaybackPosition
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Менеджер подключённых очков. Потокобезопасен (ConcurrentHashMap).
|
||||
* Раздаёт исходящие сообщения всем очкам и обрабатывает входящие.
|
||||
*/
|
||||
class GlassesHub(private val json: Json) {
|
||||
|
||||
private val sessions = ConcurrentHashMap<DefaultWebSocketServerSession, Unit>()
|
||||
|
||||
private val _connected = MutableStateFlow(0)
|
||||
val connected: StateFlow<Int> = _connected.asStateFlow()
|
||||
|
||||
private val _mode = MutableStateFlow(GlassesMode.MOVIE)
|
||||
val mode: StateFlow<GlassesMode> = _mode.asStateFlow()
|
||||
|
||||
private val _gesturesReceived = MutableStateFlow(0)
|
||||
val gesturesReceived: StateFlow<Int> = _gesturesReceived.asStateFlow()
|
||||
|
||||
/** Активная сессия чата (выставляется с «телефона»). */
|
||||
@Volatile
|
||||
var sessionId: String? = null
|
||||
|
||||
fun add(session: DefaultWebSocketServerSession) {
|
||||
sessions[session] = Unit
|
||||
_connected.value = sessions.size
|
||||
log("hub", "очки подключились, всего: ${sessions.size}")
|
||||
}
|
||||
|
||||
fun remove(session: DefaultWebSocketServerSession) {
|
||||
sessions.remove(session)
|
||||
_connected.value = sessions.size
|
||||
log("hub", "очки отключились, всего: ${sessions.size}")
|
||||
}
|
||||
|
||||
/** Отправить сообщение всем подключённым очкам. */
|
||||
suspend fun broadcast(msg: HostToGlasses) {
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
for (session in sessions.keys) {
|
||||
try {
|
||||
session.send(text)
|
||||
log("host →", text)
|
||||
} catch (e: Exception) {
|
||||
log("hub", "не удалось отправить ${msg::class.simpleName}: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Сменить режим очков и разослать SetMode. */
|
||||
suspend fun setMode(mode: GlassesMode) {
|
||||
_mode.value = mode
|
||||
broadcast(pw.binom.viewmate.core.protocol.SetMode(mode))
|
||||
}
|
||||
|
||||
/** Обработать входящее текстовое сообщение от очков. */
|
||||
suspend fun handle(session: DefaultWebSocketServerSession, text: String) {
|
||||
val msg = json.decodeFromString(GlassesToHost.serializer(), text)
|
||||
when (msg) {
|
||||
is Hello -> {
|
||||
log("glasses", "hello (app=${msg.appVersion}) → welcome")
|
||||
send(session, Welcome(mode = _mode.value, sessionId = sessionId))
|
||||
}
|
||||
|
||||
is Gesture -> {
|
||||
_gesturesReceived.value++
|
||||
log("glasses", "жест с тачпада: ${msg.gesture}")
|
||||
}
|
||||
|
||||
is GlassesStatus -> {
|
||||
log("glasses", "статус: батарея ${msg.batteryPercent}%, память ${msg.storageUsedGb}/${msg.storageTotalGb} ГБ")
|
||||
}
|
||||
|
||||
is PlaybackPosition -> {
|
||||
val state = if (msg.playing) "играет" else "пауза"
|
||||
log("glasses", "позиция: ${msg.positionMs} мс, $state")
|
||||
}
|
||||
|
||||
is GlassesOff -> {
|
||||
log("glasses", "очки выключились → пауза (${msg.reason})")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun send(session: DefaultWebSocketServerSession, msg: HostToGlasses) {
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
session.send(text)
|
||||
log("host →", text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package pw.binom.viewmate.host
|
||||
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Общий Json: полиморфизм через дискриминатор "type" (как описано в TASK.md). */
|
||||
val hostJson: Json = Json {
|
||||
classDiscriminator = "type"
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
const val GLASSES_WS_PATH = "/ws/glasses"
|
||||
|
||||
/** Ktor-модуль хоста: WebSocket-роут для подключения очков. */
|
||||
fun Application.hostModule(hub: GlassesHub) {
|
||||
install(WebSockets) {
|
||||
pingPeriodMillis = 15_000
|
||||
timeoutMillis = 15_000
|
||||
maxFrameSize = Long.MAX_VALUE
|
||||
masking = false
|
||||
}
|
||||
routing {
|
||||
webSocket(GLASSES_WS_PATH) {
|
||||
hub.add(this)
|
||||
try {
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
val text = frame.readText()
|
||||
log("glasses →", text)
|
||||
hub.handle(this, text)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log("server", "WS-ошибка: ${e.message}")
|
||||
} finally {
|
||||
hub.remove(this)
|
||||
log("server", "соединение закрыто")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package pw.binom.viewmate.host
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private val fmt: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS").withZone(ZoneId.systemDefault())
|
||||
|
||||
/** Единый формат логов сервера: timestamp + тег + сообщение. */
|
||||
fun log(tag: String, message: String) {
|
||||
println("[${fmt.format(Instant.now())}] $tag $message")
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package pw.binom.viewmate.host
|
||||
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.netty.Netty
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import pw.binom.viewmate.core.protocol.AssistantState
|
||||
import pw.binom.viewmate.core.protocol.AssistantStateMsg
|
||||
import pw.binom.viewmate.core.protocol.ChatSessionMsg
|
||||
import pw.binom.viewmate.core.protocol.MediaCommandMsg
|
||||
import pw.binom.viewmate.core.protocol.PlayVideo
|
||||
import pw.binom.viewmate.core.protocol.ShowText
|
||||
|
||||
const val HOST_PORT = 8080
|
||||
|
||||
fun main() {
|
||||
val hub = GlassesHub(hostJson)
|
||||
val server = embeddedServer(Netty, port = HOST_PORT, host = "0.0.0.0") {
|
||||
hostModule(hub)
|
||||
}
|
||||
server.start(wait = false)
|
||||
log("server", "ViewMate Host слушает ws://0.0.0.0:$HOST_PORT$GLASSES_WS_PATH")
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(Thread {
|
||||
server.stop(1_000, 3_000)
|
||||
log("server", "остановлен")
|
||||
})
|
||||
|
||||
runBlocking {
|
||||
launch {
|
||||
hub.connected.collectLatest { count ->
|
||||
log("hub", "подключено очков: $count")
|
||||
}
|
||||
}
|
||||
consoleLoop(hub, server)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun consoleLoop(hub: GlassesHub, server: EmbeddedServer<*, *>) {
|
||||
printHelp()
|
||||
while (true) {
|
||||
val line = readlnOrNull() ?: break
|
||||
val trimmed = line.trim()
|
||||
if (trimmed.isEmpty()) continue
|
||||
if (trimmed == "exit" || trimmed == "quit") {
|
||||
log("console", "выход")
|
||||
server.stop(1_000, 3_000)
|
||||
break
|
||||
}
|
||||
when (trimmed) {
|
||||
"help", "?" -> printHelp()
|
||||
else -> handleConsoleCommand(hub, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleConsoleCommand(hub: GlassesHub, line: String) {
|
||||
val parts = line.split(" ").filter { it.isNotBlank() }
|
||||
val cmd = parts.firstOrNull() ?: return
|
||||
when (cmd.lowercase()) {
|
||||
"play" -> {
|
||||
val url = parts.drop(1).joinToString(" ")
|
||||
if (url.isEmpty()) return logUsage("play <url>")
|
||||
hub.broadcast(PlayVideo(itemId = "manual", title = "Manual video", videoUrl = url))
|
||||
}
|
||||
|
||||
"mode" -> {
|
||||
val mode = parseEnum<GlassesMode>(parts.getOrNull(1))
|
||||
if (mode == null) return logUsage("mode MOVIE|CHAT")
|
||||
hub.setMode(mode)
|
||||
}
|
||||
|
||||
"state" -> {
|
||||
val state = parseEnum<AssistantState>(parts.getOrNull(1))
|
||||
if (state == null) return logUsage("state IDLE|LISTENING|THINKING|SPEAKING [seconds]")
|
||||
val seconds = parts.getOrNull(2)?.toIntOrNull() ?: 0
|
||||
hub.broadcast(AssistantStateMsg(state = state, recordingSeconds = seconds))
|
||||
}
|
||||
|
||||
"text" -> {
|
||||
val text = parts.drop(1).joinToString(" ")
|
||||
if (text.isEmpty()) return logUsage("text <текст>")
|
||||
hub.broadcast(ShowText(text = text))
|
||||
}
|
||||
|
||||
"session" -> {
|
||||
val id = parts.getOrNull(1)
|
||||
val title = parts.drop(2).joinToString(" ")
|
||||
if (id == null || title.isEmpty()) return logUsage("session <id> <title>")
|
||||
hub.sessionId = id
|
||||
hub.broadcast(ChatSessionMsg(sessionId = id, title = title))
|
||||
}
|
||||
|
||||
"cmd" -> {
|
||||
val command = parseEnum<MediaCommand>(parts.getOrNull(1))
|
||||
if (command == null) return logUsage("cmd PLAY|PAUSE|TOGGLE|SEEK_FORWARD|SEEK_BACKWARD|NEXT_AUDIO|PREV_AUDIO|CLOSE [seekMs]")
|
||||
val seekMs = parts.getOrNull(2)?.toLongOrNull() ?: 0
|
||||
hub.broadcast(MediaCommandMsg(command = command, seekMs = seekMs))
|
||||
}
|
||||
|
||||
else -> log("console", "неизвестная команда «$cmd» (help — список команд)")
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T : Enum<T>> parseEnum(value: String?): T? =
|
||||
value?.let { runCatching { enumValueOf<T>(it.uppercase()) }.getOrNull() }
|
||||
|
||||
private fun logUsage(usage: String) = log("console", "usage: $usage")
|
||||
|
||||
private fun printHelp() {
|
||||
log("console", "Команды (шлют всем подключённым очкам):")
|
||||
log("console", " play <url> — PlayVideo (грузить и играть фильм)")
|
||||
log("console", " mode MOVIE|CHAT — SetMode + сменить режим хоста")
|
||||
log("console", " state <STATE> [sec] — AssistantState: IDLE|LISTENING|THINKING|SPEAKING")
|
||||
log("console", " text <текст> — ShowText (ответ ассистента)")
|
||||
log("console", " session <id> <title> — ChatSessionMsg (переключить сессию)")
|
||||
log("console", " cmd <CMD> [seekMs] — MediaCommand: PLAY|PAUSE|TOGGLE|SEEK_FORWARD|SEEK_BACKWARD|NEXT_AUDIO|PREV_AUDIO|CLOSE")
|
||||
log("console", " help — этот список")
|
||||
log("console", " exit — остановить сервер")
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package pw.binom.viewmate.host
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
|
||||
class MainActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Отладочный хост: к нему цепляются очки по WiFi, он эмулирует
|
||||
// телефон (дубль-управление, логи, тест каналов). Пока — заглушка.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package pw.binom.viewmate.host
|
||||
|
||||
import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.plugins.websocket.webSocket
|
||||
import io.ktor.server.testing.testApplication
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readText
|
||||
import io.ktor.websocket.send
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.protocol.Gesture
|
||||
import pw.binom.viewmate.core.protocol.GlassesStatus
|
||||
import pw.binom.viewmate.core.protocol.GlassesToHost
|
||||
import pw.binom.viewmate.core.protocol.Hello
|
||||
import pw.binom.viewmate.core.protocol.HostToGlasses
|
||||
import pw.binom.viewmate.core.protocol.SetMode
|
||||
import pw.binom.viewmate.core.protocol.Welcome
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.fail
|
||||
|
||||
class HostServerTest {
|
||||
|
||||
private val json = Json {
|
||||
classDiscriminator = "type"
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
@Test
|
||||
fun helloGetsWelcome() = testApplication {
|
||||
val hub = GlassesHub(json)
|
||||
application { hostModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
send(json.encodeToString(GlassesToHost.serializer(), Hello(appVersion = "0.1.0")))
|
||||
val frame = incoming.receive() as Frame.Text
|
||||
val welcome = json.decodeFromString(HostToGlasses.serializer(), frame.readText())
|
||||
assertIs<Welcome>(welcome)
|
||||
assertEquals(GlassesMode.MOVIE, welcome.mode)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gestureIsProcessed() = testApplication {
|
||||
val hub = GlassesHub(json)
|
||||
application { hostModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
send(json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "DoubleClick")))
|
||||
send(json.encodeToString(GlassesToHost.serializer(), GlassesStatus(70, 10.5, 32.0)))
|
||||
}
|
||||
awaitTrue { hub.gesturesReceived.value == 1 }
|
||||
awaitTrue { hub.connected.value == 0 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectedCounterTracksSessions() = testApplication {
|
||||
val hub = GlassesHub(json)
|
||||
application { hostModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
client.webSocket(GLASSES_WS_PATH) { }
|
||||
awaitTrue { hub.connected.value == 0 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun broadcastReachesAllGlasses() = testApplication {
|
||||
val hub = GlassesHub(json)
|
||||
application { hostModule(hub) }
|
||||
val client = createClient { install(WebSockets) }
|
||||
|
||||
coroutineScope {
|
||||
val opened = Channel<Unit>(2)
|
||||
val received = Channel<HostToGlasses>(2)
|
||||
|
||||
val jobs = (1..2).map {
|
||||
launch {
|
||||
client.webSocket(GLASSES_WS_PATH) {
|
||||
opened.send(Unit)
|
||||
while (true) {
|
||||
val frame = incoming.receive()
|
||||
if (frame is Frame.Text) {
|
||||
received.send(json.decodeFromString(HostToGlasses.serializer(), frame.readText()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repeat(2) { opened.receive() }
|
||||
awaitTrue { hub.connected.value == 2 }
|
||||
|
||||
hub.setMode(GlassesMode.CHAT)
|
||||
|
||||
val first = received.receive()
|
||||
val second = received.receive()
|
||||
listOf(first, second).forEach {
|
||||
assertIs<SetMode>(it)
|
||||
assertEquals(GlassesMode.CHAT, it.mode)
|
||||
}
|
||||
|
||||
jobs.forEach { it.cancelAndJoin() }
|
||||
awaitTrue { hub.connected.value == 0 }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun awaitTrue(timeoutMs: Long = 10_000, condition: () -> Boolean) {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (!condition()) {
|
||||
if (System.currentTimeMillis() > deadline) fail("условие не выполнено за ${timeoutMs}мс")
|
||||
delay(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.mpp.androidSourceSetLayoutVersion=2
|
||||
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
|
||||
org.gradle.workers.max=4
|
||||
|
||||
@@ -5,6 +5,8 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
|
||||
androidTarget {
|
||||
compilerOptions {
|
||||
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||
@@ -17,6 +19,9 @@ kotlin {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package pw.binom.viewmate.core.protocol
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Состояния голосового ассистента (отображаются на панели очков). */
|
||||
@Serializable
|
||||
enum class AssistantState { IDLE, LISTENING, THINKING, SPEAKING }
|
||||
@@ -0,0 +1,54 @@
|
||||
package pw.binom.viewmate.core.protocol
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
|
||||
/**
|
||||
* Сообщения от хоста (телефона) к очкам.
|
||||
* Полиморфная сериализация: дискриминатор "type" со значением @SerialName.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface HostToGlasses
|
||||
|
||||
/** Ответ на hello: текущее состояние. */
|
||||
@Serializable
|
||||
@SerialName("welcome")
|
||||
data class Welcome(val mode: GlassesMode, val sessionId: String? = null) : HostToGlasses
|
||||
|
||||
/** Переключить режим очков. */
|
||||
@Serializable
|
||||
@SerialName("set_mode")
|
||||
data class SetMode(val mode: GlassesMode) : HostToGlasses
|
||||
|
||||
/** Команда медиа (MediaCommand: PLAY/PAUSE/TOGGLE/SEEK_FORWARD/SEEK_BACKWARD/NEXT_AUDIO/PREV_AUDIO/CLOSE). */
|
||||
@Serializable
|
||||
@SerialName("media_command")
|
||||
data class MediaCommandMsg(val command: MediaCommand, val seekMs: Long = 0) : HostToGlasses
|
||||
|
||||
/** Включить фильм: очки грузят videoUrl и играют, звук — на телефоне. */
|
||||
@Serializable
|
||||
@SerialName("play_video")
|
||||
data class PlayVideo(
|
||||
val itemId: String,
|
||||
val title: String,
|
||||
val videoUrl: String,
|
||||
val audioIndex: Int = 0,
|
||||
val startPositionMs: Long = 0,
|
||||
) : HostToGlasses
|
||||
|
||||
/** Состояние ассистента (IDLE/LISTENING/THINKING/SPEAKING) + время записи для панели. */
|
||||
@Serializable
|
||||
@SerialName("assistant_state")
|
||||
data class AssistantStateMsg(val state: AssistantState, val recordingSeconds: Int = 0) : HostToGlasses
|
||||
|
||||
/** Текст ответа ассистента (для показа по LongClick и скролла свайпами). */
|
||||
@Serializable
|
||||
@SerialName("show_text")
|
||||
data class ShowText(val text: String, val sessionId: String? = null) : HostToGlasses
|
||||
|
||||
/** Сессия чата (переключение). */
|
||||
@Serializable
|
||||
@SerialName("chat_session")
|
||||
data class ChatSessionMsg(val sessionId: String, val title: String) : HostToGlasses
|
||||
@@ -0,0 +1,40 @@
|
||||
package pw.binom.viewmate.core.protocol
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Сообщения от очков к хосту (телефону).
|
||||
* Полиморфная сериализация: дискриминатор "type" со значением @SerialName.
|
||||
*/
|
||||
@Serializable
|
||||
sealed interface GlassesToHost
|
||||
|
||||
/** Жест с тачпада очков (SDK уже распознал: Click/DoubleClick/SlideBackward/SlideForward/LongClick/TripleClick). */
|
||||
@Serializable
|
||||
@SerialName("gesture")
|
||||
data class Gesture(val gesture: String) : GlassesToHost
|
||||
|
||||
/** Периодический статус очков. */
|
||||
@Serializable
|
||||
@SerialName("glasses_status")
|
||||
data class GlassesStatus(
|
||||
val batteryPercent: Int,
|
||||
val storageUsedGb: Double,
|
||||
val storageTotalGb: Double,
|
||||
) : GlassesToHost
|
||||
|
||||
/** Позиция воспроизведения (очки — мастер таймкода, шлют регулярно). */
|
||||
@Serializable
|
||||
@SerialName("playback_position")
|
||||
data class PlaybackPosition(val positionMs: Long, val playing: Boolean) : GlassesToHost
|
||||
|
||||
/** Очки выключились / экран погас. */
|
||||
@Serializable
|
||||
@SerialName("glasses_off")
|
||||
data class GlassesOff(val reason: String = "display_off") : GlassesToHost
|
||||
|
||||
/** Запрос хосту: дай каталог/статус (после подключения). */
|
||||
@Serializable
|
||||
@SerialName("hello")
|
||||
data class Hello(val appVersion: String = "0.1.0") : GlassesToHost
|
||||
@@ -0,0 +1,82 @@
|
||||
package pw.binom.viewmate.core.protocol
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GlassesToHostTest {
|
||||
|
||||
private val json = Json {
|
||||
classDiscriminator = "type"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gestureRoundTrip() {
|
||||
val msg: GlassesToHost = Gesture(gesture = "DoubleClick")
|
||||
val text = json.encodeToString(GlassesToHost.serializer(), msg)
|
||||
assertEquals("""{"type":"gesture","gesture":"DoubleClick"}""", text)
|
||||
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
|
||||
assertIs<Gesture>(decoded)
|
||||
assertEquals("DoubleClick", decoded.gesture)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesStatusRoundTrip() {
|
||||
val msg: GlassesToHost = GlassesStatus(batteryPercent = 80, storageUsedGb = 12.5, storageTotalGb = 32.0)
|
||||
val text = json.encodeToString(GlassesToHost.serializer(), msg)
|
||||
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
|
||||
assertIs<GlassesStatus>(decoded)
|
||||
assertEquals(80, decoded.batteryPercent)
|
||||
assertEquals(12.5, decoded.storageUsedGb)
|
||||
assertEquals(32.0, decoded.storageTotalGb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playbackPositionRoundTrip() {
|
||||
val msg: GlassesToHost = PlaybackPosition(positionMs = 65_432, playing = true)
|
||||
val text = json.encodeToString(GlassesToHost.serializer(), msg)
|
||||
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
|
||||
assertIs<PlaybackPosition>(decoded)
|
||||
assertEquals(65_432, decoded.positionMs)
|
||||
assertTrue(decoded.playing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesOffRoundTripWithDefault() {
|
||||
val msg: GlassesToHost = GlassesOff()
|
||||
val text = json.encodeToString(GlassesToHost.serializer(), msg)
|
||||
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
|
||||
assertIs<GlassesOff>(decoded)
|
||||
assertEquals("display_off", decoded.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun glassesOffCustomReason() {
|
||||
val msg: GlassesToHost = GlassesOff(reason = "battery_empty")
|
||||
val decoded = json.decodeFromString(GlassesToHost.serializer(), json.encodeToString(GlassesToHost.serializer(), msg))
|
||||
assertIs<GlassesOff>(decoded)
|
||||
assertEquals("battery_empty", decoded.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun helloRoundTrip() {
|
||||
val msg: GlassesToHost = Hello()
|
||||
val text = json.encodeToString(GlassesToHost.serializer(), msg)
|
||||
val decoded = json.decodeFromString(GlassesToHost.serializer(), text)
|
||||
assertIs<Hello>(decoded)
|
||||
assertEquals("0.1.0", decoded.appVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discriminatorIsType() {
|
||||
val text = json.encodeToString(GlassesToHost.serializer(), Gesture(gesture = "Click"))
|
||||
val obj = json.parseToJsonElement(text) as JsonObject
|
||||
assertEquals("gesture", obj["type"]!!.jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package pw.binom.viewmate.core.protocol
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import pw.binom.viewmate.core.GlassesMode
|
||||
import pw.binom.viewmate.core.MediaCommand
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class HostToGlassesTest {
|
||||
|
||||
private val json = Json {
|
||||
classDiscriminator = "type"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun welcomeRoundTrip() {
|
||||
val msg: HostToGlasses = Welcome(mode = GlassesMode.CHAT, sessionId = "s-42")
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
assertEquals("""{"type":"welcome","mode":"CHAT","sessionId":"s-42"}""", text)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||
assertIs<Welcome>(decoded)
|
||||
assertEquals(GlassesMode.CHAT, decoded.mode)
|
||||
assertEquals("s-42", decoded.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun welcomeNullSessionId() {
|
||||
val msg: HostToGlasses = Welcome(mode = GlassesMode.MOVIE)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), json.encodeToString(HostToGlasses.serializer(), msg))
|
||||
assertIs<Welcome>(decoded)
|
||||
assertNull(decoded.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setModeRoundTrip() {
|
||||
val msg: HostToGlasses = SetMode(mode = GlassesMode.MOVIE)
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||
assertIs<SetMode>(decoded)
|
||||
assertEquals(GlassesMode.MOVIE, decoded.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaCommandRoundTrip() {
|
||||
val msg: HostToGlasses = MediaCommandMsg(command = MediaCommand.SEEK_FORWARD, seekMs = 30_000)
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
assertEquals("""{"type":"media_command","command":"SEEK_FORWARD","seekMs":30000}""", text)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||
assertIs<MediaCommandMsg>(decoded)
|
||||
assertEquals(MediaCommand.SEEK_FORWARD, decoded.command)
|
||||
assertEquals(30_000, decoded.seekMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaCommandDefaultSeek() {
|
||||
val msg: HostToGlasses = MediaCommandMsg(command = MediaCommand.PAUSE)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), json.encodeToString(HostToGlasses.serializer(), msg))
|
||||
assertIs<MediaCommandMsg>(decoded)
|
||||
assertEquals(0, decoded.seekMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playVideoRoundTrip() {
|
||||
val msg: HostToGlasses = PlayVideo(
|
||||
itemId = "movie-1",
|
||||
title = "Interstellar",
|
||||
videoUrl = "https://media.example.com/ns.mp4",
|
||||
audioIndex = 2,
|
||||
startPositionMs = 123_456,
|
||||
)
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||
assertIs<PlayVideo>(decoded)
|
||||
assertEquals("movie-1", decoded.itemId)
|
||||
assertEquals("Interstellar", decoded.title)
|
||||
assertEquals("https://media.example.com/ns.mp4", decoded.videoUrl)
|
||||
assertEquals(2, decoded.audioIndex)
|
||||
assertEquals(123_456, decoded.startPositionMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun assistantStateRoundTrip() {
|
||||
val msg: HostToGlasses = AssistantStateMsg(state = AssistantState.LISTENING, recordingSeconds = 7)
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
assertEquals("""{"type":"assistant_state","state":"LISTENING","recordingSeconds":7}""", text)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||
assertIs<AssistantStateMsg>(decoded)
|
||||
assertEquals(AssistantState.LISTENING, decoded.state)
|
||||
assertEquals(7, decoded.recordingSeconds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun showTextRoundTrip() {
|
||||
val msg: HostToGlasses = ShowText(text = "Привет, мир", sessionId = "s-1")
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||
assertIs<ShowText>(decoded)
|
||||
assertEquals("Привет, мир", decoded.text)
|
||||
assertEquals("s-1", decoded.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatSessionRoundTrip() {
|
||||
val msg: HostToGlasses = ChatSessionMsg(sessionId = "ch-7", title = "Планы на выходные")
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), msg)
|
||||
val decoded = json.decodeFromString(HostToGlasses.serializer(), text)
|
||||
assertIs<ChatSessionMsg>(decoded)
|
||||
assertEquals("ch-7", decoded.sessionId)
|
||||
assertEquals("Планы на выходные", decoded.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discriminatorIsType() {
|
||||
val text = json.encodeToString(HostToGlasses.serializer(), SetMode(GlassesMode.CHAT))
|
||||
val obj = json.parseToJsonElement(text) as JsonObject
|
||||
assertEquals("set_mode", obj["type"]!!.jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user