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:
@@ -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