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:
Hermes Agent
2026-08-11 12:35:30 +03:00
parent 5268a12d20
commit 9271517b3c
16 changed files with 751 additions and 66 deletions
+1
View File
@@ -2,3 +2,4 @@
build/ build/
local.properties local.properties
*.hprof *.hprof
local.properties
+23 -42
View File
@@ -1,50 +1,31 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlinx.serialization) alias(libs.plugins.kotlinx.serialization)
alias(libs.plugins.compose.compiler) application
} }
android { kotlin { jvmToolchain(17) }
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
}
}
dependencies { 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(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()
} }
-12
View File
@@ -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)
}
}
}
+2
View File
@@ -1,3 +1,5 @@
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.mpp.androidSourceSetLayoutVersion=2
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
org.gradle.workers.max=4
+5
View File
@@ -5,6 +5,8 @@ plugins {
} }
kotlin { kotlin {
jvmToolchain(17)
androidTarget { androidTarget {
compilerOptions { compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
@@ -17,6 +19,9 @@ kotlin {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.core)
} }
commonTest.dependencies {
implementation(kotlin("test"))
}
androidMain.dependencies { androidMain.dependencies {
implementation(libs.kotlinx.coroutines.android) 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)
}
}