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(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 > parseEnum(value: String?): T? =
+ value?.let { runCatching { enumValueOf(it.uppercase()) }.getOrNull() }
+
+private fun logUsage(usage: String) = log("console", "usage: $usage")
+
+private fun printHelp() {
+ log("console", "Команды (шлют всем подключённым очкам):")
+ log("console", " play — PlayVideo (грузить и играть фильм)")
+ log("console", " mode MOVIE|CHAT — SetMode + сменить режим хоста")
+ log("console", " state [sec] — AssistantState: IDLE|LISTENING|THINKING|SPEAKING")
+ log("console", " text <текст> — ShowText (ответ ассистента)")
+ log("console", " session — ChatSessionMsg (переключить сессию)")
+ log("console", " 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)
+ 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(2)
+ val received = Channel(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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(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)
+ }
+}