diff --git a/.gitignore b/.gitignore index 46f87c9..2f2b2d0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ build/ local.properties *.hprof +local.properties diff --git a/app-host/build.gradle.kts b/app-host/build.gradle.kts index 831e5a4..56a52b7 100644 --- a/app-host/build.gradle.kts +++ b/app-host/build.gradle.kts @@ -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() } diff --git a/app-host/src/main/AndroidManifest.xml b/app-host/src/main/AndroidManifest.xml deleted file mode 100644 index e0205c0..0000000 --- a/app-host/src/main/AndroidManifest.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt new file mode 100644 index 0000000..c9d1902 --- /dev/null +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/GlassesHub.kt @@ -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() + + private val _connected = MutableStateFlow(0) + val connected: StateFlow = _connected.asStateFlow() + + private val _mode = MutableStateFlow(GlassesMode.MOVIE) + val mode: StateFlow = _mode.asStateFlow() + + private val _gesturesReceived = MutableStateFlow(0) + val gesturesReceived: StateFlow = _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) + } +} diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/HostServer.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/HostServer.kt new file mode 100644 index 0000000..72e10c8 --- /dev/null +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/HostServer.kt @@ -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", "соединение закрыто") + } + } + } +} diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/Log.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/Log.kt new file mode 100644 index 0000000..ba75a09 --- /dev/null +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/Log.kt @@ -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") +} diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt new file mode 100644 index 0000000..a3c5657 --- /dev/null +++ b/app-host/src/main/kotlin/pw/binom/viewmate/host/Main.kt @@ -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 ") + hub.broadcast(PlayVideo(itemId = "manual", title = "Manual video", videoUrl = url)) + } + + "mode" -> { + val mode = parseEnum(parts.getOrNull(1)) + if (mode == null) return logUsage("mode MOVIE|CHAT") + hub.setMode(mode) + } + + "state" -> { + val state = parseEnum(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 ") + 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 — остановить сервер") +} diff --git a/app-host/src/main/kotlin/pw/binom/viewmate/host/MainActivity.kt b/app-host/src/main/kotlin/pw/binom/viewmate/host/MainActivity.kt deleted file mode 100644 index 6c2599e..0000000 --- a/app-host/src/main/kotlin/pw/binom/viewmate/host/MainActivity.kt +++ /dev/null @@ -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, он эмулирует - // телефон (дубль-управление, логи, тест каналов). Пока — заглушка. - } -} diff --git a/app-host/src/test/kotlin/pw/binom/viewmate/host/HostServerTest.kt b/app-host/src/test/kotlin/pw/binom/viewmate/host/HostServerTest.kt new file mode 100644 index 0000000..1324fec --- /dev/null +++ b/app-host/src/test/kotlin/pw/binom/viewmate/host/HostServerTest.kt @@ -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) + } + } +} diff --git a/gradle.properties b/gradle.properties index 1f5a100..63b2f31 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/lib-core/build.gradle.kts b/lib-core/build.gradle.kts index a11dc2f..580f8c4 100644 --- a/lib-core/build.gradle.kts +++ b/lib-core/build.gradle.kts @@ -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) } diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/AssistantState.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/AssistantState.kt new file mode 100644 index 0000000..82f17c4 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/AssistantState.kt @@ -0,0 +1,7 @@ +package pw.binom.viewmate.core.protocol + +import kotlinx.serialization.Serializable + +/** Состояния голосового ассистента (отображаются на панели очков). */ +@Serializable +enum class AssistantState { IDLE, LISTENING, THINKING, SPEAKING } diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt new file mode 100644 index 0000000..82e3117 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/HostToGlasses.kt @@ -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 diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt new file mode 100644 index 0000000..78391ba --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt @@ -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 diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt new file mode 100644 index 0000000..1b0a8bb --- /dev/null +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/GlassesToHostTest.kt @@ -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) + } +} diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt new file mode 100644 index 0000000..5edfb70 --- /dev/null +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/protocol/HostToGlassesTest.kt @@ -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) + } +}