From 6cd4a8de4391ba28284c48aed054d108cf77370f Mon Sep 17 00:00:00 2001 From: subochev Date: Sat, 29 Aug 2026 23:06:52 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=8F=D1=80=D0=BA=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=D1=8E/WiFi=20=D0=BE=D1=87=D0=BA=D0=BE=D0=B2=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20Mercury=20+=20=D0=BD=D0=B0=D0=BA?= =?UTF-8?q?=D0=BE=D0=BF=D0=BB=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=BE?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mercury (app-phone): управление яркостью (с авто-выкл. перед применением) и WiFi (скан/подключение/список сетей с паролем); добавлено право BLUETOOTH_ADVERTISE в манифест и runtime-запрос (без него GATT-сервер не рекламируется → очки не подключаются) - логирование (lib-core core.log): BatchingLogCollector + LogSink + LogSpool + LokiLogSink; цепочка очки→телефон→Loki; lib-core log() переадресован в коллектор - STT: Qwen3-ASR через pw.binom.asr:asr-qwen3-android, убран in-house whisper-фоллбек - audio-sync: P-регулятор с насыщением вместо фиксированной скорости - media-split: очки только video, телефон только audio (UI-чипы) - Components: форматирование размера через Locale.ROOT --- .../pw/binom/viewmate/glasses/GlassesApp.kt | 27 +++ .../glasses/GlassesDownloadManager.kt | 3 +- .../viewmate/glasses/GlassesLinkLogSink.kt | 20 ++ .../kotlin/pw/binom/viewmate/glasses/Log.kt | 17 +- app-phone/build.gradle.kts | 5 +- app-phone/src/main/AndroidManifest.xml | 1 + .../binom/viewmate/phone/AudioSyncPlayer.kt | 20 +- .../binom/viewmate/phone/BtServerTransport.kt | 9 +- .../pw/binom/viewmate/phone/GlassesServer.kt | 14 ++ .../kotlin/pw/binom/viewmate/phone/Log.kt | 15 +- .../pw/binom/viewmate/phone/MercuryBridge.kt | 102 +++++++- .../pw/binom/viewmate/phone/PhoneApp.kt | 79 ++++--- .../binom/viewmate/phone/stt/Qwen3AsrStt.kt | 62 ++--- .../pw/binom/viewmate/phone/stt/SttDebug.kt | 7 +- .../pw/binom/viewmate/phone/stt/WhisperStt.kt | 46 ---- .../pw/binom/viewmate/phone/ui/Components.kt | 3 +- .../binom/viewmate/phone/ui/DetailsScreen.kt | 50 ++-- .../binom/viewmate/phone/ui/MercuryPanel.kt | 223 ++++++++++++++++-- .../viewmate/phone/AudioSyncPlayerTest.kt | 21 +- .../binom/viewmate/core/log/FileLogSpool.kt | 68 ++++++ .../pw/binom/viewmate/core/log/LogLock.kt | 16 ++ .../pw/binom/viewmate/core/log/SystemClock.kt | 5 + .../kotlin/pw/binom/viewmate/core/Log.kt | 13 +- .../viewmate/core/log/BatchingLogCollector.kt | 121 ++++++++++ .../pw/binom/viewmate/core/log/HttpLogSink.kt | 36 +++ .../pw/binom/viewmate/core/log/LogBatch.kt | 44 ++++ .../pw/binom/viewmate/core/log/LogClock.kt | 19 ++ .../pw/binom/viewmate/core/log/LogLock.kt | 10 + .../pw/binom/viewmate/core/log/LogSink.kt | 20 ++ .../pw/binom/viewmate/core/log/LogSpool.kt | 41 ++++ .../pw/binom/viewmate/core/log/LokiLogSink.kt | 94 ++++++++ .../binom/viewmate/core/phone/PhoneActions.kt | 5 +- .../binom/viewmate/core/protocol/Messages.kt | 11 + .../core/log/BatchingLogCollectorTest.kt | 140 +++++++++++ .../binom/viewmate/core/log/LogSpoolTest.kt | 50 ++++ .../viewmate/core/log/LokiLogSinkTest.kt | 40 ++++ .../binom/viewmate/core/log/FileLogSpool.kt | 68 ++++++ .../pw/binom/viewmate/core/log/LogLock.kt | 16 ++ .../pw/binom/viewmate/core/log/SystemClock.kt | 5 + .../viewmate/core/log/FileLogSpoolTest.kt | 66 ++++++ 40 files changed, 1409 insertions(+), 203 deletions(-) create mode 100644 app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesLinkLogSink.kt delete mode 100644 app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt create mode 100644 lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt create mode 100644 lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt create mode 100644 lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt create mode 100644 lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/BatchingLogCollector.kt create mode 100644 lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/HttpLogSink.kt create mode 100644 lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogBatch.kt create mode 100644 lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogClock.kt create mode 100644 lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt create mode 100644 lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSink.kt create mode 100644 lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSpool.kt create mode 100644 lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LokiLogSink.kt create mode 100644 lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/BatchingLogCollectorTest.kt create mode 100644 lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LogSpoolTest.kt create mode 100644 lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LokiLogSinkTest.kt create mode 100644 lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt create mode 100644 lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt create mode 100644 lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt create mode 100644 lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/log/FileLogSpoolTest.kt diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt index c8af0c7..3ce6558 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt @@ -4,7 +4,13 @@ import android.app.Application import android.content.Context import com.rayneo.arsdk.android.MercurySDK import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.CoroutineScope +import pw.binom.viewmate.core.log.BatchingLogCollector +import pw.binom.viewmate.core.log.FileLogSpool +import pw.binom.viewmate.core.log.SystemClock /** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */ data class ChatEntry(val role: String, val text: String) @@ -45,6 +51,13 @@ class GlassesApp : Application() { lateinit var downloadManager: GlassesDownloadManager private set + /** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */ + lateinit var logCollector: BatchingLogCollector + private set + + /** Скоуп фоновых корутин (таймер лог-коллектора). */ + private val bgScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val prefs by lazy { getSharedPreferences("viewmate_prefs", Context.MODE_PRIVATE) } @@ -123,6 +136,20 @@ class GlassesApp : Application() { ) hostConnection.start() log("app", "HostConnection стартует: mDNS-поиск, fallback: ${GlassesConfig.HOST_URL}") + + // Лог-коллектор: пачки → телефон; обрыв связи — спул в filesDir, ретраи таймером. + logCollector = BatchingLogCollector( + device = "glasses", + sink = GlassesLinkLogSink(hostConnection), + spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 32), + clock = SystemClock(), + flushIntervalMs = 60_000, + maxBatchEntries = 500, + ) + logCollector.start(bgScope) + attachLogCollector(logCollector) + pw.binom.viewmate.core.logCollector = logCollector + log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)") } companion object { diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManager.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManager.kt index 2452ac6..bceb1fa 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManager.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManager.kt @@ -99,7 +99,8 @@ internal class GlassesStateStore(private val file: File) { /** * Менеджер скачивания контента в память очков (аналог телефонного DownloadManager). - * Каталог: filesDir/viewmate/{itemId}/video.mkv + audio-N.ogg. Скачивание пишется в .part + * Каталог: filesDir/viewmate/{itemId}/video.mkv — на очки уходит только видео, + * звук (audio-N.ogg) не скачивается, он лежит на телефоне. Скачивание пишется в .part * и докачивается с места обрыва (Range). Прогресс шлём на телефон через [send]. */ class GlassesDownloadManager( diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesLinkLogSink.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesLinkLogSink.kt new file mode 100644 index 0000000..d878377 --- /dev/null +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesLinkLogSink.kt @@ -0,0 +1,20 @@ +package pw.binom.viewmate.glasses + +import pw.binom.viewmate.core.log.LogBatch +import pw.binom.viewmate.core.log.LogSink +import pw.binom.viewmate.core.protocol.LogBatchMsg + +/** + * LogSink коллектора очков: пачка уходит на телефон по связи очков↔телефон + * ([LogBatchMsg]). Связи нет → false: пачка прихраняется в споуле очков + * (filesDir) и ретраится после восстановления связи. + */ +class GlassesLinkLogSink( + private val connection: HostConnection, +) : LogSink { + override suspend fun send(batch: LogBatch): Boolean { + if (!connection.connected.value) return false + connection.sendToHost(LogBatchMsg(entries = batch.entries)) + return true + } +} diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt index a5fe211..94e8a8e 100644 --- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt +++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt @@ -1,6 +1,21 @@ package pw.binom.viewmate.glasses -/** Единый лог очков: простой println (работает и в unit-тестах). */ +import pw.binom.viewmate.core.log.BatchingLogCollector + +/** + * Единый лог очков: println (видно в юнит-тестах) + лог-коллектор — пачки + * уходят на телефон ([GlassesLinkLogSink]); при обрыве связи пачки + * прихраняются в споуле очков (filesDir) и ретраятся позже. + */ +@Volatile +private var logCollector: BatchingLogCollector? = null + +/** Подключить коллектор (GlassesApp.onCreate, после HostConnection). */ +fun attachLogCollector(collector: BatchingLogCollector) { + logCollector = collector +} + fun log(tag: String, message: String) { println("[view-mate] $tag $message") + logCollector?.record(tag, message) } diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts index 019f665..de62d16 100644 --- a/app-phone/build.gradle.kts +++ b/app-phone/build.gradle.kts @@ -104,9 +104,12 @@ dependencies { // LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU) implementation(libs.litertlm.android) - // Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack + // Silero-VAD на sherpa-onnx (SherpaStreamingVad / VadSegmenter), JitPack implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6") + // Офлайн-распознавание Qwen3-ASR (asr-kmp, caffeine nexus) — Qwen3AsrStt + implementation("pw.binom.asr:asr-qwen3-android:4") + // WS-сервер телефона (CIO — работает на Android) implementation("io.ktor:ktor-server-core:3.3.0") implementation("io.ktor:ktor-server-cio:3.3.0") diff --git a/app-phone/src/main/AndroidManifest.xml b/app-phone/src/main/AndroidManifest.xml index e06869a..fc99a34 100644 --- a/app-phone/src/main/AndroidManifest.xml +++ b/app-phone/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ + diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt index ae45e77..946fe1c 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt @@ -31,8 +31,14 @@ internal data class SyncDecision( /** Гистерезис: внутри зоны [-TRIGGER_BAND_MS; +TRIGGER_BAND_MS] скорость 1f — без дребезга. */ internal const val TRIGGER_BAND_MS = 150L -/** Темп догонки/отстаивания за пределами гистерезиса: 1.02 / 0.98 на каждый цикл. */ -internal const val DRIFT_RATE = 0.02f +/** + * Коэффициент пропорциональной коррекции: скорость = 1 ± [DRIFT_GAIN]·|diff| (пока diff < гистерезиса — 1f). + * 0.0002f/мс → 300мс рассинхрона ≈ 1.06/0.94, 500мс и дальше упирается в [MAX_DRIFT_SPEED]. + */ +internal const val DRIFT_GAIN = 0.0002f + +/** Потолок изменения темпа (±10%): дальше слышно, что «не в свой темп». */ +internal const val MAX_DRIFT_SPEED = 0.10f /** Экстремальный рассинхрон: |diff| > HARD_SEEK_THRESHOLD_MS — скорость не нагонит, жёсткий seekTo. */ internal const val HARD_SEEK_THRESHOLD_MS = 2000L @@ -40,8 +46,9 @@ internal const val HARD_SEEK_THRESHOLD_MS = 2000L /** * Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android): * - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп); - * - audio отстаёт от video (diff < -band) → speed = 1 + [DRIFT_RATE] (нагоняем); - * - audio впереди video (diff > +band) → speed = 1 - [DRIFT_RATE] (тормозим); + * - иначе — пропорциональная коррекция: звук отстаёт (diff < 0) → 1 + [DRIFT_GAIN]·|diff| (нагоняем), + * звук впереди (diff > 0) → 1 − [DRIFT_GAIN]·|diff| (тормозим); результат ограничивается + * [MAX_DRIFT_SPEED] (±10%) — дальше темп не растим, чтобы не звучать чужеродно; * - |diff| > [HARD_SEEK_THRESHOLD_MS] → seekToMs = позиция очков; speed = 1f (жёстко, далее мягко); * - isPlaying очков отличается от состояния аудио → play/pause (как раньше, на speed не влияет). */ @@ -55,9 +62,8 @@ internal fun decideSync( val seekToMs = if (abs(diff) > HARD_SEEK_THRESHOLD_MS) videoPosition else null val speed = when { seekToMs != null -> 1f - diff < -TRIGGER_BAND_MS -> 1f + DRIFT_RATE - diff > TRIGGER_BAND_MS -> 1f - DRIFT_RATE - else -> 1f + abs(diff) <= TRIGGER_BAND_MS -> 1f + else -> (1f - DRIFT_GAIN * diff.toFloat()).coerceIn(1f - MAX_DRIFT_SPEED, 1f + MAX_DRIFT_SPEED) } val playing = when { videoPlaying == audioPlaying -> null diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/BtServerTransport.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/BtServerTransport.kt index e06c857..cab5671 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/BtServerTransport.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/BtServerTransport.kt @@ -44,12 +44,17 @@ class BtServerTransport( /** * Runtime-пермишены Bluetooth, зависящие от версии платформы * (TASK-transport.md п.3, п.5: Android 12+ / до 12): - * 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery), + * 31+ — BLUETOOTH_CONNECT (SCAN — для сканирования; ADVERTISE — для GATT-сервера + * очков, Mercury-управление), * ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме). */ fun runtimePermissions(): Array = if (Build.VERSION.SDK_INT >= 31) { - arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN) + arrayOf( + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_ADVERTISE, + ) } else { arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN) } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt index c55e0ee..c4363ad 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString +import pw.binom.viewmate.core.log.LogEntry import pw.binom.viewmate.core.net.GlassesServerTransport import pw.binom.viewmate.core.phone.GlassesSender import pw.binom.viewmate.core.phone.PhoneState @@ -22,6 +23,7 @@ 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.LogBatchMsg import pw.binom.viewmate.core.protocol.PlaybackPosition import pw.binom.viewmate.core.protocol.SttAudio import pw.binom.viewmate.core.protocol.SttCancel @@ -97,6 +99,13 @@ class GlassesHub( */ var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null + /** + * Хук на лог-пачки очков: телефон прибавляет записи в свой лог-коллектор + * (BatchingLogCollector.recordBatch) — они уйдут на сервер вместе со своими. + */ + @Volatile + var onLogBatch: ((List) -> Unit)? = null + /** * Стриминговое распознавание речи с очков. Лениво создаётся через * [sttFactory] (модели во filesDir/models); onSilence30s/onAutoFinished @@ -195,6 +204,11 @@ class GlassesHub( onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0) } + is LogBatchMsg -> { + log("glasses", "лог-пачка с очков: ${msg.entries.size} записей") + onLogBatch?.invoke(msg.entries) + } + is SttAudio -> { val s = stt ?: sttFactory?.invoke()?.also { stt = it } s?.accept(msg.data) diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt index d88f6cd..2bddaf0 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt @@ -1,13 +1,25 @@ package pw.binom.viewmate.phone import android.util.Log +import pw.binom.viewmate.core.log.BatchingLogCollector /** * Единый лог телефона. * На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах - * (android.util.Log недоступен — метод без @JvmStatic фолбэкнет в println), + * (android.util.Log недоступен — метод без @JvmStatic фолбэктнет в println), * чтобы не падать. + * + * Плюс лог-коллектор: пачки (свои + пришедшие от очков) уходят на сервер + * (HttpLogSink); при сбое — прихраняются в споуле и ретраятся позже. */ +@Volatile +private var logCollector: BatchingLogCollector? = null + +/** Подключить коллектор (PhoneApp.onCreate). */ +fun attachLogCollector(collector: BatchingLogCollector) { + logCollector = collector +} + fun log(tag: String, message: String) { val full = "[view-mate] $tag $message" runCatching { @@ -15,4 +27,5 @@ fun log(tag: String, message: String) { }.onFailure { println(full) } + logCollector?.record(tag, message) } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt index 5768555..dcddd1c 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt @@ -16,6 +16,8 @@ import kotlinx.coroutines.launch import kotlinx.serialization.json.jsonObject import pw.binom.mercury.DiscoveredGlasses import pw.binom.mercury.RayNeoMercury +import pw.binom.mercury.model.MyWifiInfo +import pw.binom.mercury.pair.PairManager /** * Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE). @@ -38,6 +40,11 @@ object MercuryBridge { val glassesName: String? = null, val statusText: String = "не подключено", val lastError: String? = null, + val brightness: Int = 0, + val automaticBrightness: Boolean = false, + val wifiEnabled: Boolean = false, + val wifiSsid: String? = null, + val wifiNetworks: List = emptyList(), ) private val _state = MutableStateFlow(UiState()) @@ -55,6 +62,9 @@ object MercuryBridge { is RayNeoMercury.Event.Connected -> { _state.value = _state.value.copy(connected = true) setStatus("подключено (BLE)") + // Запрашиваем актуальный статус (яркость/WiFi/батарея), чтобы + // ползунок яркости и переключатель WiFi показывали реальные значения. + scope.launch { mercury?.pullGeneralStatus() } // Включаем heartbeat очков (каждые 3с они пингуют нас) — иначе // при гашении экрана очки рвут BLE-связь через ~3 секунды. // Повторяем, пока очки не будут готовы (они переподключаются @@ -94,8 +104,20 @@ object MercuryBridge { is RayNeoMercury.Event.AppInstallProgress -> log("app", "${event.pkg}: ${event.progress}%") is RayNeoMercury.Event.WirelessAdbAddress -> setStatus("ADB: ${event.address}") is RayNeoMercury.Event.LogcatLine -> log("logcat", event.line) - is RayNeoMercury.Event.GeneralStatusUpdate -> log("status", "brightness=${event.status.brightness} deepSuspend=${event.status.deepSuspend}") - is RayNeoMercury.Event.WifiScanResult -> log("wifi", "сетей: ${event.networks.size}") + is RayNeoMercury.Event.GeneralStatusUpdate -> { + val s = event.status + _state.value = _state.value.copy( + brightness = s.brightness, + automaticBrightness = s.automaticBrightness, + wifiEnabled = s.wifiEnabled, + wifiSsid = s.wifiInfo?.ssid, + ) + log("status", "brightness=${s.brightness} autoBrightness=${s.automaticBrightness} deepSuspend=${s.deepSuspend} wifi=${s.wifiEnabled} ssid=${s.wifiInfo?.ssid}") + } + is RayNeoMercury.Event.WifiScanResult -> { + _state.value = _state.value.copy(wifiNetworks = event.networks) + log("wifi", "сетей: ${event.networks.size}") + } } } } @@ -109,7 +131,8 @@ object MercuryBridge { val m = mercury ?: return false return m.hasPermissions() && ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED && - ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED } /** @@ -141,6 +164,20 @@ object MercuryBridge { setStatus("подключено (клиент)") return true } + // Без bond очки не отдают GATT-сервис — спариваем и пробуем ещё раз. + setStatus("connect не удался, спариваю…") + when (val res = m.pair(glasses)) { + is PairManager.PairingResult.Success -> { + if (m.connect(glasses, 8000)) { + _state.value = _state.value.copy(connected = true, glassesName = glasses.name) + setStatus("подключено (клиент, после pair)") + return true + } + } + is PairManager.PairingResult.Failed -> { + setStatus("спаривание не удалось: ${res.reason}") + } + } } } setError("очки не подключились за ${timeoutMillis / 1000} с") @@ -174,13 +211,68 @@ object MercuryBridge { return ok } + /** Яркость экрана очков (0..100). Авто-яркость принудительно выключается — иначе очки игнорируют ручное значение. */ + suspend fun setBrightness(value: Int): Boolean { + val v = value.coerceIn(0, 100) + // При automaticBrightness=true очки отбрасывают brightness, поэтому выключаем авто заранее. + mercury?.setAutoBrightness(false) + val ok = mercury?.setBrightness(v) ?: false + if (ok) _state.value = _state.value.copy(brightness = v, automaticBrightness = false) + setStatus(if (ok) "яркость → $v%" else "ошибка отправки (нет связи?)") + return ok + } + + /** Авто-яркость очков. */ + suspend fun setAutoBrightness(enable: Boolean): Boolean { + val ok = mercury?.setAutoBrightness(enable) ?: false + if (ok) _state.value = _state.value.copy(automaticBrightness = enable) + setStatus(if (ok) "авто-яркость → $enable" else "ошибка отправки (нет связи?)") + return ok + } + + /** Вкл/выкл WiFi-радио очков. */ + suspend fun wifiToggle(enable: Boolean): Boolean { + val ok = mercury?.wifiToggle(enable) ?: false + if (ok) _state.value = _state.value.copy(wifiEnabled = enable) + setStatus(if (ok) "WiFi → ${if (enable) "вкл" else "выкл"}" else "ошибка отправки (нет связи?)") + return ok + } + + /** Подключить очки к WiFi-сети (пароль null, если открытая). */ + suspend fun wifiConnect(ssid: String, password: String?): Boolean { + val ok = mercury?.wifiConnect(ssid, password) ?: false + setStatus(if (ok) "WiFi: подключение к «$ssid»…" else "ошибка отправки (нет связи?)") + return ok + } + + /** Забыть сохранённую WiFi-сеть на очках. */ + suspend fun wifiRemove(ssid: String): Boolean { + val ok = mercury?.wifiRemove(ssid) ?: false + setStatus(if (ok) "WiFi: забыть «$ssid»" else "ошибка отправки (нет связи?)") + return ok + } + + /** Сканировать доступные WiFi-сети (результат → state.wifiNetworks). */ + suspend fun wifiScan(): Boolean { + val ok = mercury?.wifiScan() ?: false + setStatus(if (ok) "WiFi: сканирование…" else "ошибка отправки (нет связи?)") + return ok + } + + /** Запросить актуальный статус очков (яркость/WiFi/батарея). */ + suspend fun pullStatus(): Boolean { + val ok = mercury?.pullGeneralStatus() ?: false + if (!ok) setStatus("ошибка запроса статуса (нет связи?)") + return ok + } + private fun setStatus(text: String) { log("status", text) _state.value = _state.value.copy(statusText = text, lastError = null) } private fun setError(text: String) { - Log.e(TAG, text) + log("mercury", text) _state.value = _state.value.copy(statusText = text, lastError = text) } @@ -193,6 +285,6 @@ object MercuryBridge { } private fun log(tag: String, msg: String) { - Log.d(TAG, "[$tag] $msg") + pw.binom.viewmate.phone.log(tag, msg) } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt index 0793e86..d7c23c7 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt @@ -17,6 +17,11 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import pw.binom.viewmate.core.GlassesMode +import pw.binom.viewmate.core.log.BatchingLogCollector +import pw.binom.viewmate.core.log.FileLogSpool +import pw.binom.viewmate.core.log.LokiConfig +import pw.binom.viewmate.core.log.SystemClock +import pw.binom.viewmate.core.log.lokiLogSink import pw.binom.viewmate.core.media.JellyfinClient import pw.binom.viewmate.core.media.MirrorClient import pw.binom.viewmate.core.phone.PhoneActions @@ -49,7 +54,6 @@ import pw.binom.viewmate.phone.agent.ToolsetRegistry import pw.binom.viewmate.phone.stt.PhraseRecognizer import pw.binom.viewmate.phone.stt.Qwen3AsrStt import pw.binom.viewmate.phone.stt.SttStreamer -import pw.binom.viewmate.phone.stt.WhisperStt /** * Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState, @@ -84,6 +88,10 @@ class PhoneApp : Application() { /** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */ val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) } + /** Лог-коллектор: свои записи + пришедшие от очков → сервер (спул — filesDir/logspool). */ + lateinit var logCollector: BatchingLogCollector + private set + /** История общения с ассистентом (SQLite, "assistant.db"). */ val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) } @@ -345,9 +353,9 @@ class PhoneApp : Application() { } /** - * Лениво создаёт стриминговое распознавание (модели во filesDir/models — + * Лениво создаёт стриминговое распознавание (модели Qwen3-ASR во filesDir/models — * кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба - * на первый SttAudio; тяжёлое создание Whisper — один раз на процесс. + * на первый SttAudio; тяжёлое создание Qwen3-ASR — один раз на процесс. */ private fun ensureStt(): SttStreamer? { synchronized(sttLock) { @@ -357,39 +365,24 @@ class PhoneApp : Application() { val qwenEnc = File(modelsDir, "encoder.int8.onnx") val qwenDec = File(modelsDir, "decoder.int8.onnx") val qwenTok = File(modelsDir, "tokenizer") - var stt: PhraseRecognizer - if (STT_BACKEND == "qwen3_asr" && - qwenConv.isFile && qwenEnc.isFile && qwenDec.isFile && qwenTok.isDirectory - ) { - stt = Qwen3AsrStt( - convFrontendPath = qwenConv.absolutePath, - encoderPath = qwenEnc.absolutePath, - decoderPath = qwenDec.absolutePath, - tokenizerDir = qwenTok.absolutePath, - numThreads = 4, - hotwords = STT_HOTWORDS, - ) - } else { - val encoder = File(modelsDir, "small-encoder.int8.onnx") - val decoder = File(modelsDir, "small-decoder.int8.onnx") - val tokens = File(modelsDir, "small-tokens.txt") - if (!encoder.isFile || !decoder.isFile || !tokens.isFile) { - log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен") - return null - } - stt = WhisperStt( - encoderPath = encoder.absolutePath, - decoderPath = decoder.absolutePath, - tokensPath = tokens.absolutePath, - numThreads = 4, - ) + if (!qwenConv.isFile || !qwenEnc.isFile || !qwenDec.isFile || !qwenTok.isDirectory) { + log("stt", "модели Qwen3-ASR не найдены в ${modelsDir.absolutePath} — STT недоступен") + return null } + val stt: PhraseRecognizer = Qwen3AsrStt( + convFrontendPath = qwenConv.absolutePath, + encoderPath = qwenEnc.absolutePath, + decoderPath = qwenDec.absolutePath, + tokenizerDir = qwenTok.absolutePath, + numThreads = 4, + hotwords = STT_HOTWORDS, + ) val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) { File(filesDir, "stt_dump_phone.raw") } else { null } - log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)") + log("stt", "бэкенд: qwen3_asr (hotwords: $STT_HOTWORDS)") val s = SttStreamer( stt = stt, onSilence30s = { @@ -406,7 +399,7 @@ class PhoneApp : Application() { ) sttStreamer = s log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}") - log("stt", "SttStreamer готов ($STT_BACKEND)") + log("stt", "SttStreamer готов (qwen3_asr)") return s } } @@ -452,6 +445,27 @@ class PhoneApp : Application() { nsd.publish() server.hub.sttFactory = { ensureStt() } server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } } + + // Лог-коллектор: свои записи + пачки от очков → Loki. instance в Loki + // выставляется по source записи ("phone"/"glasses"), спул — filesDir/logspool. + logCollector = BatchingLogCollector( + device = "phone", + sink = lokiLogSink(), + spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 64), + clock = SystemClock(), + flushIntervalMs = 60_000, + maxBatchEntries = 500, + ) + logCollector.start(scope) + attachLogCollector(logCollector) + pw.binom.viewmate.core.logCollector = logCollector + server.hub.onLogBatch = { entries -> logCollector.recordBatch(entries) } + log( + "server", + "лог-коллектор запущен (sink: loki ${LokiConfig().url}, instance по source; " + + "спул: filesDir/logspool)", + ) + scope.launch { assistantLoop() } val lastPositionAt = AtomicLong(0) @@ -534,9 +548,6 @@ class PhoneApp : Application() { lateinit var instance: PhoneApp private set - /** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */ - private const val STT_BACKEND = "qwen3_asr" - /** Подсказки словаря Qwen3-ASR (запятая-разделитель). */ private const val STT_HOTWORDS = "тулсет,тулы,умеешь" } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt index 8f20555..1663f9e 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt @@ -1,51 +1,29 @@ package pw.binom.viewmate.phone.stt -import com.k2fsa.sherpa.onnx.OfflineModelConfig -import com.k2fsa.sherpa.onnx.OfflineQwen3AsrModelConfig -import com.k2fsa.sherpa.onnx.OfflineRecognizer -import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig +import pw.binom.asr.qwen3.Qwen3Config +import pw.binom.asr.qwen3.createQwen3Sherpa -/** Офлайн-распознавание: sherpa-onnx + Qwen3-ASR-0.6B (int8), CPU. */ +/** Офлайн-распознавание: asr-qwen3 (sherpa-onnx + Qwen3-ASR-0.6B int8, CPU). */ class Qwen3AsrStt( - private val convFrontendPath: String, - private val encoderPath: String, - private val decoderPath: String, - private val tokenizerDir: String, - private val numThreads: Int = 4, - private val hotwords: String = "", + convFrontendPath: String, + encoderPath: String, + decoderPath: String, + tokenizerDir: String, + numThreads: Int = 4, + hotwords: String = "", ) : PhraseRecognizer { - private val recognizer: OfflineRecognizer = OfflineRecognizer( - null, - OfflineRecognizerConfig( - modelConfig = OfflineModelConfig( - qwen3Asr = OfflineQwen3AsrModelConfig( - convFrontend = convFrontendPath, - encoder = encoderPath, - decoder = decoderPath, - tokenizer = tokenizerDir, - maxTotalLen = 512, - maxNewTokens = 128, - temperature = 1e-6f, - topP = 0.8f, - seed = 42, - hotwords = hotwords, - ), - numThreads = numThreads, - provider = "cpu", - modelType = "qwen3_asr", - ), + /** Тяжёлая нативка создаётся один раз; recognize() — single-shot (стрим на вызов). */ + private val recognizer = createQwen3Sherpa( + Qwen3Config( + convFrontendPath = convFrontendPath, + encoderPath = encoderPath, + decoderPath = decoderPath, + tokenizerDir = tokenizerDir, + numThreads = numThreads, + hotwords = hotwords, ), ) /** Распознать 16 кГц mono PCM. Возвращает текст. */ - override fun recognize(samples: FloatArray): String { - val stream = recognizer.createStream() - try { - stream.acceptWaveform(samples, 16000) - recognizer.decode(stream) - return recognizer.getResult(stream).text.trim() - } finally { - stream.release() - } - } -} \ No newline at end of file + override fun recognize(samples: FloatArray): String = recognizer.recognize(samples, 16000) +} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttDebug.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttDebug.kt index eaee2fd..e0fa08c 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttDebug.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttDebug.kt @@ -1,15 +1,12 @@ package pw.binom.viewmate.phone.stt import android.content.Context -import android.util.Log import java.io.File import java.io.FileInputStream /** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */ object SttDebug { - private const val TAG = "stt" - /** * Распознаёт [wavPath] по фразам (VAD + Whisper). * Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null. @@ -54,14 +51,14 @@ object SttDebug { log("[stt] ВЕСЬ ТЕКСТ: $all") all } catch (t: Throwable) { - Log.e(TAG, "[stt] ошибка: ${t.message}", t) + log("[stt] ошибка: ${t.message}") t.printStackTrace() null } } private fun log(msg: String) { - Log.i(TAG, msg) + pw.binom.viewmate.phone.log("stt", msg) println(msg) } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt deleted file mode 100644 index 77e26a7..0000000 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt +++ /dev/null @@ -1,46 +0,0 @@ -package pw.binom.viewmate.phone.stt - -import com.k2fsa.sherpa.onnx.OfflineModelConfig -import com.k2fsa.sherpa.onnx.OfflineRecognizer -import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig -import com.k2fsa.sherpa.onnx.OfflineWhisperModelConfig - -/** Офлайн-распознавание: sherpa-onnx + Whisper-small (int8), CPU. */ -class WhisperStt( - private val encoderPath: String, - private val decoderPath: String, - private val tokensPath: String, - private val numThreads: Int = 4, - private val language: String = "ru", -) : PhraseRecognizer { - private val recognizer: OfflineRecognizer = OfflineRecognizer( - null, - OfflineRecognizerConfig( - modelConfig = OfflineModelConfig( - whisper = OfflineWhisperModelConfig( - encoder = encoderPath, - decoder = decoderPath, - language = language, - task = "transcribe", - tailPaddings = -1, - ), - tokens = tokensPath, - numThreads = numThreads, - provider = "cpu", - modelType = "whisper", - ), - ), - ) - - /** Распознать 16 кГц mono PCM. Возвращает текст. */ - override fun recognize(samples: FloatArray): String { - val stream = recognizer.createStream() - try { - stream.acceptWaveform(samples, 16000) - recognizer.decode(stream) - return recognizer.getResult(stream).text.trim() - } finally { - stream.release() - } - } -} diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt index fdab495..58138e7 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.MirrorJob +import java.util.Locale /** Экраны приложения (простая навигация без nav-библиотеки). */ enum class Screen { @@ -168,7 +169,7 @@ fun bytesToSize(bytes: Long?): String { val gb = 1024.0 * 1024 * 1024 val mb = 1024.0 * 1024 return when { - bytes >= gb -> "%.2f ГБ".format(bytes / gb) + bytes >= gb -> String.format(Locale.ROOT, "%.2f ГБ", bytes / gb) bytes >= mb -> "%d МБ".format((bytes / mb).toLong()) else -> "%d КБ".format((bytes / 1024).toLong()) } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt index a73f5e5..a47b75e 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt @@ -42,7 +42,6 @@ import kotlinx.coroutines.launch import pw.binom.viewmate.core.media.JellyfinItem import pw.binom.viewmate.core.media.MirrorJob import pw.binom.viewmate.core.media.MirrorStatus -import pw.binom.viewmate.core.phone.trackFileName import pw.binom.viewmate.core.protocol.DownloadPhase import pw.binom.viewmate.phone.FileDownloadState import pw.binom.viewmate.phone.PhoneApp @@ -392,26 +391,28 @@ fun DetailsScreen( keys.forEach { key -> val name = finalFileName(key) val (finalFile, partFile) = trackFiles[key] ?: return@forEach + val isVideo = key == "video" val phoneRaw = phoneFileState(finalFile, partFile) val phonePercent = if (phoneRaw.first == FileDownloadState.DOWNLOADING) { dm.percentForFile(item.Id, name) ?: dm.overallPercent(item.Id) } else { phoneRaw.second } - val glassesState = if (glassesFully) { - FileDownloadState.DONE to 100 - } else { - glassesFileState(perFileProgress[name]) + // Видео лежит на очках, звук — на телефоне: чип только у того устройства, куда файл вообще идёт + val state = when { + isVideo && glassesFully -> FileDownloadState.DONE to 100 + isVideo -> glassesFileState(perFileProgress[name]) + else -> phoneRaw.first to phonePercent } DownloadFileRow( name = name, - sizeLabel = fileSizeOrProgress( - sizeBytes = knownSizeByName[name], - phone = phoneRaw.first to phonePercent, - glasses = glassesState, - ), - phone = phoneRaw.first to phonePercent, - glasses = glassesState, + device = if (isVideo) "очки" else "телефон", + sizeLabel = if (state.first == FileDownloadState.DOWNLOADING) { + "⏳ ${state.second}%" + } else { + bytesToSize(knownSizeByName[name]) + }, + state = state, ) } } @@ -498,24 +499,13 @@ fun DetailsScreen( } } -/** Метка «размер/прогресс» для строки файла: при качке — ⏳ N%, иначе размер («--», если размера ещё нет). */ -private fun fileSizeOrProgress( - sizeBytes: Long?, - phone: Pair, - glasses: Pair, -): String = when { - phone.first == FileDownloadState.DOWNLOADING -> "⏳ ${phone.second}%" - glasses.first == FileDownloadState.DOWNLOADING -> "⏳ ${glasses.second}%" - else -> bytesToSize(sizeBytes) -} - -/** Строка файла: имя · размер, ниже — чипы «телефон» / «очки». */ +/** Строка файла: имя · размер, ниже — чип того устройства, куда файл идёт (видео — очки, звук — телефон). */ @Composable private fun DownloadFileRow( name: String, + device: String, sizeLabel: String, - phone: Pair, - glasses: Pair, + state: Pair, ) { Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp) @@ -523,13 +513,9 @@ private fun DownloadFileRow( modifier = Modifier.padding(start = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text("телефон", color = PhoneMuted, fontSize = 9.sp) + Text(device, color = PhoneMuted, fontSize = 9.sp) Spacer(modifier = Modifier.width(6.dp)) - FileChip(phone) - Spacer(modifier = Modifier.width(12.dp)) - Text("очки", color = PhoneMuted, fontSize = 9.sp) - Spacer(modifier = Modifier.width(6.dp)) - FileChip(glasses) + FileChip(state) } } } diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt index fe622e4..9ab2cb3 100644 --- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt +++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt @@ -5,34 +5,60 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import pw.binom.viewmate.phone.MercuryBridge /** * Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury. - * Минимум: найти очки → подключить → погасить/включить экран (deepSuspend). + * Управление очками: подключение, экран, яркость и WiFi. */ @Composable fun MercuryPanel() { val scope = rememberCoroutineScope() val state by MercuryBridge.state.collectAsState() - val busy = false + + var brightness by remember { mutableIntStateOf(state.brightness) } + var autoBright by remember { mutableStateOf(state.automaticBrightness) } + var wifiOn by remember { mutableStateOf(state.wifiEnabled) } + var ssid by remember { mutableStateOf("") } + var wifiPass by remember { mutableStateOf("") } + var scanning by remember { mutableStateOf(false) } + + // Синхронизируем локальные поля со статусом очков (после pullGeneralStatus). + LaunchedEffect(state.brightness) { brightness = state.brightness } + LaunchedEffect(state.automaticBrightness) { autoBright = state.automaticBrightness } + LaunchedEffect(state.wifiEnabled) { wifiOn = state.wifiEnabled } Column( modifier = Modifier @@ -43,7 +69,7 @@ fun MercuryPanel() { .padding(12.dp), ) { Text( - text = "🛰 Mercury BLE (полевой тест)", + text = "🛰 Mercury BLE (управление очками)", color = PhoneText, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, @@ -65,12 +91,7 @@ fun MercuryPanel() { Row(modifier = Modifier.padding(top = 10.dp)) { Button( - onClick = { - scope.launch { - MercuryBridge.connectFirst() - } - }, - enabled = !busy, + onClick = { scope.launch { MercuryBridge.connectFirst() } }, colors = ButtonDefaults.buttonColors( containerColor = PhoneBlue, contentColor = PhoneBlueText, @@ -80,9 +101,7 @@ fun MercuryPanel() { } Spacer(modifier = Modifier.width(8.dp)) Button( - onClick = { - scope.launch { MercuryBridge.disconnect() } - }, + onClick = { scope.launch { MercuryBridge.disconnect() } }, enabled = state.connected, colors = ButtonDefaults.buttonColors( containerColor = PhoneSurface, @@ -95,9 +114,7 @@ fun MercuryPanel() { Row(modifier = Modifier.padding(top = 8.dp)) { Button( - onClick = { - scope.launch { MercuryBridge.screenOff() } - }, + onClick = { scope.launch { MercuryBridge.screenOff() } }, enabled = state.connected, colors = ButtonDefaults.buttonColors( containerColor = Color(0xFF2B2F36), @@ -108,9 +125,7 @@ fun MercuryPanel() { } Spacer(modifier = Modifier.width(8.dp)) Button( - onClick = { - scope.launch { MercuryBridge.screenOn() } - }, + onClick = { scope.launch { MercuryBridge.screenOn() } }, enabled = state.connected, colors = ButtonDefaults.buttonColors( containerColor = PhoneAccent, @@ -120,5 +135,177 @@ fun MercuryPanel() { Text("☀️ включить экран", fontSize = 12.sp) } } + + // ---- Яркость ---- + Text( + text = "Яркость: $brightness%", + color = PhoneText, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 12.dp), + ) + Slider( + value = brightness.toFloat(), + onValueChange = { brightness = it.roundToInt() }, + valueRange = 0f..100f, + enabled = state.connected, + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Button( + onClick = { scope.launch { MercuryBridge.setBrightness(brightness) } }, + enabled = state.connected, + colors = ButtonDefaults.buttonColors( + containerColor = PhoneBlue, + contentColor = PhoneBlueText, + ), + ) { + Text("Применить яркость", fontSize = 12.sp) + } + Spacer(modifier = Modifier.width(10.dp)) + Switch( + checked = autoBright, + onCheckedChange = { nv -> + autoBright = nv + scope.launch { MercuryBridge.setAutoBrightness(nv) } + }, + enabled = state.connected, + ) + Text("авто-яркость", color = PhoneMuted, fontSize = 11.sp) + } + + // ---- WiFi ---- + Row( + modifier = Modifier.padding(top = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "WiFi", + color = PhoneText, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + Spacer(modifier = Modifier.width(10.dp)) + Switch( + checked = wifiOn, + onCheckedChange = { nv -> + wifiOn = nv + scope.launch { MercuryBridge.wifiToggle(nv) } + }, + enabled = state.connected, + ) + Text(if (wifiOn) "вкл" else "выкл", color = PhoneMuted, fontSize = 11.sp) + Spacer(modifier = Modifier.width(10.dp)) + Button( + onClick = { scope.launch { + if (!state.wifiEnabled) MercuryBridge.wifiToggle(true) + scanning = true + delay(1500) // дать радио подняться + MercuryBridge.wifiScan() + delay(5000) // окно ожидания ответа очков + scanning = false + } }, + enabled = state.connected, + colors = ButtonDefaults.buttonColors( + containerColor = PhoneSurface, + contentColor = PhoneMuted, + ), + ) { + Text(if (scanning) "Сканируем…" else "Сканировать", fontSize = 12.sp) + } + if (state.wifiSsid != null) { + Text( + "· ${state.wifiSsid}", + color = PhoneBlueText, + fontSize = 11.sp, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + TextField( + value = ssid, + onValueChange = { ssid = it }, + label = { Text("SSID сети", fontSize = 11.sp) }, + singleLine = true, + enabled = state.connected, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + ) + TextField( + value = wifiPass, + onValueChange = { wifiPass = it }, + label = { Text("Пароль (пусто = открытая сеть)", fontSize = 11.sp) }, + singleLine = true, + enabled = state.connected, + modifier = Modifier.fillMaxWidth().padding(top = 6.dp), + ) + Button( + onClick = { scope.launch { MercuryBridge.wifiConnect(ssid, wifiPass.ifBlank { null }) } }, + enabled = state.connected && ssid.isNotBlank(), + colors = ButtonDefaults.buttonColors( + containerColor = PhoneBlue, + contentColor = PhoneBlueText, + ), + ) { + Text("Подключить к сети", fontSize = 12.sp) + } + + if (state.wifiNetworks.isEmpty() && !scanning) { + Text( + "Список пуст: включи WiFi и нажми «Сканировать».", + color = PhoneMuted, + fontSize = 11.sp, + modifier = Modifier.padding(top = 8.dp), + ) + } + + if (state.wifiNetworks.isNotEmpty()) { + Text( + "Найдено сетей: ${state.wifiNetworks.size}", + color = PhoneMuted, + fontSize = 11.sp, + modifier = Modifier.padding(top = 8.dp), + ) + LazyColumn( + modifier = Modifier.heightIn(max = 220.dp).fillMaxWidth().padding(top = 4.dp), + ) { + items(state.wifiNetworks) { net -> + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Text( + "${net.ssid} ${net.level}dBm${if (net.saved) " · сохр." else ""}", + color = PhoneText, + fontSize = 11.sp, + ) + Row( + modifier = Modifier.padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Button( + onClick = { scope.launch { + val pwd = if (net.needsPassword()) wifiPass.ifBlank { null } else null + MercuryBridge.wifiConnect(net.ssid, pwd) + } }, + enabled = state.connected, + ) { + Text("Подкл.", fontSize = 11.sp) + } + if (net.saved) { + Button( + onClick = { scope.launch { MercuryBridge.wifiRemove(net.ssid) } }, + enabled = state.connected, + colors = ButtonDefaults.buttonColors( + containerColor = PhoneSurface, + contentColor = PhoneMuted, + ), + ) { + Text("Забыть", fontSize = 11.sp) + } + } + } + } + } + } + } } } diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt index d4ae085..0f44d92 100644 --- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt +++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt @@ -33,7 +33,7 @@ class AudioSyncPlayerTest { @Test fun speedsUpWhenAudioLagsBehind() { val d = decideSync(audioPosition = 10_000, videoPosition = 10_300, audioPlaying = true, videoPlaying = true) - assertEquals(1f + DRIFT_RATE, d.speed, "звук отстаёт — догоняем ускорением ${1f + DRIFT_RATE}") + assertEquals(1f + 300f * DRIFT_GAIN, d.speed, "звук отстаёт — догоняем ускорением ${1f + 300f * DRIFT_GAIN}") assertNull(d.seekToMs, "diff=300мс внутри HARD-порога — seek не нужен") assertNull(d.playing) } @@ -41,9 +41,22 @@ class AudioSyncPlayerTest { @Test fun slowsDownWhenAudioAhead() { val d = decideSync(audioPosition = 10_300, videoPosition = 10_000, audioPlaying = true, videoPlaying = true) - assertEquals(1f - DRIFT_RATE, d.speed, "звук впереди — тормозим до ${1f - DRIFT_RATE}") + assertEquals(1f - 300f * DRIFT_GAIN, d.speed, "звук впереди — тормозим до ${1f - 300f * DRIFT_GAIN}") assertNull(d.seekToMs) - assertNull(d.playing) + } + + @Test + fun smallDriftJustOutsideBandIsProportional() { + val d = decideSync(audioPosition = 10_151, videoPosition = 10_000, audioPlaying = true, videoPlaying = true) + assertEquals(1f - 151f * DRIFT_GAIN, d.speed, "diff=151мс (чуть за зоной) — слабый пропорциональный темп") + } + + @Test + fun speedSaturatesAtMaxDrift() { + val behind = decideSync(audioPosition = 9_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true) + assertEquals(1f + MAX_DRIFT_SPEED, behind.speed, "diff=−1000мс → 1+0.2 упирается в потолок ${1f + MAX_DRIFT_SPEED}") + val ahead = decideSync(audioPosition = 11_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true) + assertEquals(1f - MAX_DRIFT_SPEED, ahead.speed, "diff=+1000мс → 1−0.2 упирается в потолок ${1f - MAX_DRIFT_SPEED}") } @Test @@ -73,7 +86,7 @@ class AudioSyncPlayerTest { fun hardThresholdBoundaryDoesNotSeek() { val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true) assertNull(d.seekToMs, "diff=2000мс не БОЛЬШЕ порога — подгоняем скоростью, без рывка") - assertEquals(1f + DRIFT_RATE, d.speed) + assertEquals(1f + MAX_DRIFT_SPEED, d.speed, "diff=2000мс → 1+0.4 — упирается в потолок ${1f + MAX_DRIFT_SPEED}") } @Test diff --git a/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt b/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt new file mode 100644 index 0000000..9fdce1f --- /dev/null +++ b/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt @@ -0,0 +1,68 @@ +package pw.binom.viewmate.core.log + +import java.io.File + +class FileLogSpool( + dir: String, + maxFiles: Int, +) : LogSpool { + private val filesDir: File = File(dir).also { if (!it.exists()) it.mkdirs() } + private val limit = maxFiles + + /** Сериал следующих файлов: продолжение после старейшего существующего. */ + private var seq: Long = currentFiles().mapNotNull { it.seqNumber() }.maxOrNull() ?: 0L + + override fun write(batch: LogBatch) { + seq += 1 + File(filesDir, "$PREFIX$seq.json").writeText(LogBatch.encodeBatch(batch)) + trim() + } + + override fun peek(): LogBatch? { + var file = oldestFile() + while (file != null) { + val batch = decodeQuiet(file) + if (batch != null) return batch + file.delete() // битый файл не должен затыкать очередь + file = oldestFile() + } + return null + } + + override fun take(): LogBatch? { + var file = oldestFile() + while (file != null) { + val batch = decodeQuiet(file) + file.delete() + if (batch != null) return batch + file = oldestFile() + } + return null + } + + override fun pendingCount(): Int = currentFiles().size + + private fun oldestFile(): File? = currentFiles().minByOrNull { it.seqNumber() } + + private fun currentFiles(): List = filesDir + .listFiles { f -> f.isFile && f.name.startsWith(PREFIX) && f.name.endsWith(".json") } + ?.toList() + ?: emptyList() + + private fun File.seqNumber(): Long = + name.substringAfter(PREFIX).removeSuffix(".json").toLongOrNull() ?: 0L + + private fun decodeQuiet(file: File): LogBatch? = + runCatching { LogBatch.decodeBatch(file.readText()) }.getOrNull() + + /** Потолок: выбросить старейшие файлы сверх [limit]. */ + private fun trim() { + val files = currentFiles().sortedBy { it.seqNumber() } + if (files.size <= limit) return + files.take(files.size - limit).forEach { it.delete() } + } + + private companion object { + const val PREFIX = "logspool-" + } +} diff --git a/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt b/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt new file mode 100644 index 0000000..3014843 --- /dev/null +++ b/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt @@ -0,0 +1,16 @@ +package pw.binom.viewmate.core.log + +import java.util.concurrent.locks.ReentrantLock + +actual class LogLock { + private val delegate = ReentrantLock() + + actual fun withLock(action: () -> T): T { + delegate.lock() + return try { + action() + } finally { + delegate.unlock() + } + } +} diff --git a/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt b/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt new file mode 100644 index 0000000..cf1a8af --- /dev/null +++ b/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt @@ -0,0 +1,5 @@ +package pw.binom.viewmate.core.log + +actual class SystemClock : LogClock { + override fun nowMillis(): Long = System.currentTimeMillis() +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt index 8fa7b1f..0a9ae6f 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt @@ -1,6 +1,17 @@ package pw.binom.viewmate.core -/** Единый лог общего слоя (KMP): без java.time, просто тег + сообщение. */ +import pw.binom.viewmate.core.log.BatchingLogCollector + +/** + * Единый лог общего слоя (KMP): без java.time, просто тег + сообщение. + * + * Приложение подключает свой коллектор через [logCollector] (в onCreate), + * и каждый вызов [log] попадает в него (дальше — в спул / на сервер / Loki). + * По умолчанию коллектор не задан — лог только в println. + */ +var logCollector: BatchingLogCollector? = null + fun log(tag: String, message: String) { println("[view-mate] $tag $message") + logCollector?.record(tag, message) } diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/BatchingLogCollector.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/BatchingLogCollector.kt new file mode 100644 index 0000000..0d36be8 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/BatchingLogCollector.kt @@ -0,0 +1,121 @@ +package pw.binom.viewmate.core.log + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * «Абстрактный коллектор» лог-пачек: собирает записи в батчи и по времени + * шлёт их «вниз» через [sink] (абстракция следующего коллектора). + * + * Смысл: + * - [record]/[recordBatch] — синхронно, с любой нитки: запись в буфер (лок), + * при переполнении [maxBatchEntries] — немедленный flush; + * - таймер (период [flushIntervalMs]) — собирает буфер в [LogBatch] и шлёт в [sink]; + * - «не получилось послать» ([LogSink.send] = false / бросил) — пачка + * **прихраняется в [spool]** и на каждом следующем тике ретраится ДО новых + * пачек (FIFO: старое раньше); + * - «чужие» записи (например, логи очков, пришедшие на телефон) — [recordBatch] + * с сохранением их [LogEntry.source]/[LogEntry.ts]. + * + * Поток: буфер — из любых ниток (под [LogLock]); спул и отправка — только + * таймер-корутина (однопоточно). + */ +class BatchingLogCollector( + /** Устройство-носитель коллектора: "glasses" / "phone". */ + val device: String, + /** «Следующий» коллектор: куда шлём пачки. */ + private val sink: LogSink, + /** Куда прихраним не отправленные пачки (retry позже). */ + private val spool: LogSpool, + /** Часы для меток времени записей. */ + private val clock: LogClock, + /** Период отправки пачек, мс. */ + private val flushIntervalMs: Long = 30_000L, + /** Лимит записей в одной пачке — превышение триггерит немедленный flush. */ + private val maxBatchEntries: Int = 500, +) { + private val buffer = ArrayList() + private val lock = LogLock() + + /** Потолок буфера в памяти (страховка, если sink долго не принимал). */ + private val maxBuffer: Int = maxBatchEntries * 20 + + @Volatile + private var timerJob: Job? = null + + @Volatile + private var scope: CoroutineScope? = null + + /** Записать свою лог-строку (синхронно, с любой нитки). */ + fun record(tag: String, message: String) { + recordBatch(listOf(LogEntry(clock.nowMillis(), tag, message, device))) + } + + /** + * Прибавить «чужие» записи (готовые [LogEntry] — со своими ts/source): + * телефон так пересылает логи очков. + */ + fun recordBatch(entries: List) { + if (entries.isEmpty()) return + val overflowed = lock.withLock { + buffer.addAll(entries) + val drop = buffer.size - maxBuffer + if (drop > 0) repeat(drop) { buffer.removeAt(0) } + buffer.size >= maxBatchEntries + } + if (overflowed) scope?.launch { flush() } + } + + /** Запустить таймер: каждые [flushIntervalMs] — flush (спул-ретраи + новый батч). */ + fun start(scope: CoroutineScope) { + if (timerJob != null) return + this.scope = scope + timerJob = scope.launch { + while (isActive) { + delay(flushIntervalMs) + runCatching { flush() } + } + } + } + + /** Остановить таймер (буфер/спул не трогаются — дошлёт следующий запуск). */ + fun stop() { + timerJob?.cancel() + timerJob = null + scope = null + } + + /** + * Цикл отправки: + * 1) ретраи спула — только старейшая пачка; не слано — ждём следующий тик + * (новый батч не «перескакивает» через неё, порядок сохраняется); + * 2) новый батч из буфера; не слано — прихраняем в [spool]. + */ + suspend fun flush() { + val spooled = spool.peek() + if (spooled != null) { + if (sendBatch(spooled)) { + spool.take() + } + return + } + val batch = drainBuffer() ?: return + if (!sendBatch(batch)) { + spool.write(batch) + } + } + + private suspend fun sendBatch(batch: LogBatch): Boolean = + runCatching { sink.send(batch) }.getOrDefault(false) + + /** Взять все накопленные записи в одну пачку (null — пусто). */ + private fun drainBuffer(): LogBatch? = lock.withLock { + if (buffer.isEmpty()) return@withLock null + val batch = LogBatch(device, buffer.toList()) + buffer.clear() + batch + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/HttpLogSink.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/HttpLogSink.kt new file mode 100644 index 0000000..fef1466 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/HttpLogSink.kt @@ -0,0 +1,36 @@ +package pw.binom.viewmate.core.log + +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import pw.binom.viewmate.core.media.defaultHttpClient + +/** + * [LogSink] по HTTP: POST JSON-пачки на [url] (заголовок X-API-Key, если [apiKey] задан). + * 2xx — принято (true); любой другой ответ/сетевая ошибка — false (пачка уйдёт в спул). + */ +class HttpLogSink( + private val client: HttpClient, + private val url: String, + private val apiKey: String? = null, +) : LogSink { + override suspend fun send(batch: LogBatch): Boolean { + val response: HttpResponse = runCatching { + client.post(url) { + if (apiKey != null) header("X-API-Key", apiKey) + contentType(ContentType.Application.Json) + setBody(LogBatch.encodeBatch(batch)) + } + }.getOrNull() ?: return false + return response.status.isSuccess() + } +} + +/** Готовый HTTP-лог-синк со своим клиентом: вызывающая сторона не знает про Ktor. */ +fun httpLogSink(url: String, apiKey: String? = null): LogSink = + HttpLogSink(defaultHttpClient(), url, apiKey) diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogBatch.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogBatch.kt new file mode 100644 index 0000000..002f830 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogBatch.kt @@ -0,0 +1,44 @@ +package pw.binom.viewmate.core.log + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Одна лог-запись. + * [ts] — часы устройства-источника, epoch-мс. + * [tag] — подсистема (stt, hub, mc, …). + * [source] — кто сгенерировал ("glasses" / "phone") — чтобы телефон мог пересылать + * логи очков на сервер с сохранением источника. + */ +@Serializable +data class LogEntry( + val ts: Long, + val tag: String, + val message: String, + val source: String = "unknown", +) + +/** + * Пачка логов — единица передачи в «следующий» коллектор. + * [device] — кто шлёт эту пачку (устройство-коллектор). + */ +@Serializable +data class LogBatch( + val device: String, + val entries: List, +) { + companion object { + private val json: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + /** Пачка → JSON-строка (файл спула). */ + fun encodeBatch(batch: LogBatch): String = json.encodeToString(serializer(), batch) + + /** JSON-строка → пачка; некорректный/битый текст — null. */ + fun decodeBatch(text: String): LogBatch? = runCatching { + json.decodeFromString(serializer(), text) + }.getOrNull() + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogClock.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogClock.kt new file mode 100644 index 0000000..7ad79f0 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogClock.kt @@ -0,0 +1,19 @@ +package pw.binom.viewmate.core.log + +/** Часы для лог-записей: у common-кода нет java.time — устройство-носитель подаёт свои. */ +fun interface LogClock { + fun nowMillis(): Long +} + +/** Стенговые (wall-clock) часы — JVM-реализация (Android/JVM: `System.currentTimeMillis`). */ +expect class SystemClock() : LogClock + +/** Зависанный (fixed) ход времени для юнит-тестов. */ +class FixedClock(private var now: Long = 0L) : LogClock { + override fun nowMillis(): Long = now + + /** Продвинуть «время» на [ms]. */ + fun advance(ms: Long) { + now += ms + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt new file mode 100644 index 0000000..ab6c8c8 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt @@ -0,0 +1,10 @@ +package pw.binom.viewmate.core.log + +/** + * Лока (thread-safe) для буфера коллектора: KMP-common не даёт нативных лок-ов — + * каждая платформа подставляет свою (Android/JVM: `java.util.concurrent.locks.ReentrantLock`). + */ +expect class LogLock() { + /** Выполнить [action] под локом; вернуть результат. */ + fun withLock(action: () -> T): T +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSink.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSink.kt new file mode 100644 index 0000000..f80b2c9 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSink.kt @@ -0,0 +1,20 @@ +package pw.binom.viewmate.core.log + +/** + * Абстракция «следующего» получателя лог-пачек (сервер у телефона, сам телефон + * у очков, чёрная дыра в тестах). Коллектор не знает про транспорт/протокол — + * только про результат отправки. + */ +fun interface LogSink { + /** + * Отправить [batch] вниз по цепочке. + * @return `true` — принято (пачка потреблена); `false` — не принято + * (коллектор положит пачку в спул и попробует позже). + */ + suspend fun send(batch: LogBatch): Boolean +} + +/** LogSink, который пачки проглатывает (логирование отключено / нет цели). */ +object NoopLogSink : LogSink { + override suspend fun send(batch: LogBatch): Boolean = true +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSpool.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSpool.kt new file mode 100644 index 0000000..d35c1ed --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSpool.kt @@ -0,0 +1,41 @@ +package pw.binom.viewmate.core.log + +/** + * Хранилище не отправленных пачек (спул): «не удалось послать → прихраним у себя + * и позже попробуем ещё». FIFO: первым ретраится самая старая пачка. + * + * Доступ — из одного потока (таймер-корутина коллектора): peek/take на каждом + * тике, write — при неудачной отправке. + */ +interface LogSpool { + /** Добавить [batch] в конец очереди. */ + fun write(batch: LogBatch) + + /** Самая старая пачка без удаления (null — пусто). */ + fun peek(): LogBatch? + + /** Самая старая пачка с удалением (null — пусто). */ + fun take(): LogBatch? + + /** Сколько пачек ждёт отправки. */ + fun pendingCount(): Int +} + +/** + * In-память спул без персистентности (тесты, «прихранил в процессе»): + * очередь с потолком [maxBatches] — при переполнении выбрасывается старейшая. + */ +class InMemoryLogSpool(private val maxBatches: Int = 32) : LogSpool { + private val queue = ArrayDeque() + + override fun write(batch: LogBatch) { + if (queue.size >= maxBatches) queue.removeFirst() + queue.addLast(batch) + } + + override fun peek(): LogBatch? = queue.firstOrNull() + + override fun take(): LogBatch? = queue.removeFirstOrNull() + + override fun pendingCount(): Int = queue.size +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LokiLogSink.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LokiLogSink.kt new file mode 100644 index 0000000..c80d38f --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LokiLogSink.kt @@ -0,0 +1,94 @@ +package pw.binom.viewmate.core.log + +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.http.isSuccess +import pw.binom.viewmate.core.media.defaultHttpClient +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Коллектор в Loki (Grafana): POST JSON в [LokiConfig.url] (Basic-auth). + * + * Важное: instance каждого stream'а = [LogEntry.source] записи ("phone"/"glasses"), + * то есть в Loki сразу видно, откуда лог — с телефона или с очков. Пачка на + * телефоне может содержать и то, и другое (телефон пересылает логи очков), + * поэтому группируем по source и шлём несколько stream'ов одним запросом. + */ +class LokiLogSink( + private val client: HttpClient, + private val config: LokiConfig = LokiConfig(), +) : LogSink { + + private val authHeader = "Basic " + base64("${config.user}:${config.password}") + private val json = Json { ignoreUnknownKeys = true } + + override suspend fun send(batch: LogBatch): Boolean { + if (batch.entries.isEmpty()) return true + val payload = buildLokiPush(batch.entries, config.job) + val response: HttpResponse = runCatching { + client.post(config.url) { + header("Authorization", authHeader) + contentType(ContentType.Application.Json) + setBody(json.encodeToString(LokiPush.serializer(), payload)) + } + }.getOrNull() ?: return false + return response.status.isSuccess() + } +} + +/** Параметры Loki. Логин/пароль/адрес зашиты по умолчанию (см. запрос пользователя). */ +data class LokiConfig( + val url: String = "https://loki.binom.pw/loki/api/v1/push", + val user: String = "loki-ingest", + val password: String = "q0OH8na60bE_KhGc6c1YOsUuFPrLjw2K", + val job: String = "view-mate", +) + +/** Готовый Loki-синк со своим клиентом: вызывающая сторона не знает про Ktor/Basic-auth. */ +fun lokiLogSink(config: LokiConfig = LokiConfig()): LogSink = + LokiLogSink(defaultHttpClient(), config) + +@Serializable +internal data class LokiStream(val stream: Map, val values: List>) + +@Serializable +internal data class LokiPush(val streams: List) + +internal fun buildLokiPush(entries: List, job: String): LokiPush { + val streams = entries + .groupBy { it.source.ifBlank { "phone" } } + .map { (source, es) -> + val values = es + .map { e -> listOf((e.ts * 1_000_000).toString(), "${e.tag}: ${e.message}") } + .sortedBy { it[0] } + LokiStream(stream = mapOf("job" to job, "instance" to source), values = values) + } + return LokiPush(streams = streams) +} + +private val B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + +/** Минимальный base64 (только кодирование) — для Basic-auth, без новых зависимостей. */ +private fun base64(input: String): String { + val bytes = input.encodeToByteArray() + val out = StringBuilder() + var i = 0 + while (i < bytes.size) { + val b0 = bytes[i].toInt() and 0xFF + val b1 = if (i + 1 < bytes.size) bytes[i + 1].toInt() and 0xFF else 0 + val b2 = if (i + 2 < bytes.size) bytes[i + 2].toInt() and 0xFF else 0 + val triple = (b0 shl 16) or (b1 shl 8) or b2 + out.append(B64[(triple ushr 18) and 0x3F]) + out.append(B64[(triple ushr 12) and 0x3F]) + out.append(if (i + 1 < bytes.size) B64[(triple ushr 6) and 0x3F] else '=') + out.append(if (i + 2 < bytes.size) B64[triple and 0x3F] else '=') + i += 3 + } + return out.toString() +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt index cf434e1..721c81c 100644 --- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt @@ -128,8 +128,9 @@ class PhoneActions( } /** - * Скачать контент в память очков: DownloadFiles с presigned URL всех файлов - * (video.mkv + audio-N.ogg) и известными размерами дорожек [sizes] (key → байт). + * Скачать контент в память очков: DownloadFiles с presigned URL только видео (video.mkv). + * Звук на очки не идёт — аудио-дорожки скачиваются только на телефон. + * [sizes] — известные размеры дорожек (key → байт). */ suspend fun downloadToGlasses( itemId: String, 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 index 03a607e..af36f7b 100644 --- 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 @@ -3,6 +3,8 @@ package pw.binom.viewmate.core.protocol import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import pw.binom.viewmate.core.log.LogEntry + /** * Сообщения от очков к хосту (телефону). * Полиморфная сериализация: дискриминатор "type" со значением @SerialName. @@ -102,3 +104,12 @@ data class SttAudio(val data: ByteArray) : GlassesToHost { @Serializable @SerialName("stop_stt") data class StopStt(val cancel: Boolean) : GlassesToHost + +/** + * Лог-пачка очков (очки → телефон). Телефон прибавляет эти записи к своему + * коллектору и пересылает на сервер вместе со своими лог-записями; + * источник — [LogEntry.source]. Старые очки такое сообщение не шлют. + */ +@Serializable +@SerialName("log_batch") +data class LogBatchMsg(val entries: List) : GlassesToHost diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/BatchingLogCollectorTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/BatchingLogCollectorTest.kt new file mode 100644 index 0000000..610edd6 --- /dev/null +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/BatchingLogCollectorTest.kt @@ -0,0 +1,140 @@ +package pw.binom.viewmate.core.log + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest + +class BatchingLogCollectorTest { + + /** Фейк «следующего» коллектора: фиксирует пачки, [fail] — имитация сбоя. */ + class FakeSink(var fail: Boolean = false) : LogSink { + val sent = mutableListOf() + + override suspend fun send(batch: LogBatch): Boolean { + if (fail) return false + sent += batch + return true + } + } + + @Test + fun `timer flush sends buffered entries`() = runTest { + val sink = FakeSink() + val collector = BatchingLogCollector( + device = "glasses", + sink = sink, + spool = InMemoryLogSpool(), + clock = FixedClock(1000), + flushIntervalMs = 100, + maxBatchEntries = 10, + ) + @OptIn(ExperimentalCoroutinesApi::class) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + collector.start(scope) + + collector.record("tag1", "hello") + collector.record("tag2", "world") + + testScheduler.advanceTimeBy(150) + + val batch = sink.sent.single() + assertEquals(2, batch.entries.size) + assertEquals("glasses", batch.device) + assertEquals(listOf("tag1", "tag2"), batch.entries.map { it.tag }) + assertEquals("glasses", batch.entries[0].source) + scope.cancel() + } + + @Test + fun `failed sink spools batch, later flush retries`() = runTest { + val sink = FakeSink(fail = true) + val spool = InMemoryLogSpool() + val collector = BatchingLogCollector("phone", sink, spool, FixedClock()) + + collector.record("a", "1") + collector.flush() + assertEquals(0, sink.sent.size) + assertEquals(1, spool.pendingCount()) + + sink.fail = false + collector.flush() // ретраи спула + assertEquals(1, sink.sent.size) + assertEquals("a", sink.sent[0].entries.single().tag) + assertEquals(0, spool.pendingCount()) + } + + @Test + fun `spool backlog blocks new batches, FIFO order kept`() = runTest { + val sink = FakeSink(fail = true) + val spool = InMemoryLogSpool() + val collector = BatchingLogCollector("phone", sink, spool, FixedClock()) + + collector.record("a", "1") + collector.flush() // batch1 → спул + collector.record("b", "2") + collector.flush() // спул затык — batch2 остаётся в буфере + assertEquals(1, spool.pendingCount()) + + sink.fail = false + collector.flush() // доразгрузили спул (batch1) + collector.flush() // теперь batch2 + assertEquals(2, sink.sent.size) + assertEquals(listOf("a", "b"), sink.sent.flatMap { it.entries }.map { it.tag }) + assertEquals(0, spool.pendingCount()) + } + + @Test + fun `foreign entries keep their source and ts`() = runTest { + val sink = FakeSink() + val collector = BatchingLogCollector("phone", sink, InMemoryLogSpool(), FixedClock(999)) + + collector.recordBatch(listOf(LogEntry(42, "mc", "glasses event", "glasses"))) + collector.record("phone", "own") + collector.flush() + + val batch = sink.sent.single() + assertEquals("phone", batch.device) + assertEquals(2, batch.entries.size) + assertEquals("glasses", batch.entries[0].source) + assertEquals(42, batch.entries[0].ts) + assertEquals("phone", batch.entries[1].source) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `overflowing batch flushes immediately`() = runTest { + val sink = FakeSink() + val collector = BatchingLogCollector( + device = "glasses", + sink = sink, + spool = InMemoryLogSpool(), + clock = FixedClock(), + flushIntervalMs = 60_000, + maxBatchEntries = 2, + ) + val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler)) + collector.start(scope) + + collector.record("a", "1") + collector.record("b", "2") // буфер == лимит → немедленный flush + testScheduler.advanceTimeBy(0) + + val batch = sink.sent.single() + assertEquals(2, batch.entries.size) + scope.cancel() + } + + @Test + fun `empty recordBatch is no-op`() = runTest { + val sink = FakeSink() + val collector = BatchingLogCollector("phone", sink, InMemoryLogSpool(), FixedClock()) + collector.recordBatch(emptyList()) + collector.flush() + assertNull(sink.sent.firstOrNull()) + } +} diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LogSpoolTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LogSpoolTest.kt new file mode 100644 index 0000000..646548c --- /dev/null +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LogSpoolTest.kt @@ -0,0 +1,50 @@ +package pw.binom.viewmate.core.log + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class LogSpoolTest { + + private fun batch(tag: String) = LogBatch("phone", listOf(LogEntry(0, tag, tag, "phone"))) + + @Test + fun `in-memory spool is FIFO with cap dropping oldest`() { + val spool = InMemoryLogSpool(maxBatches = 2) + spool.write(batch("1")) + spool.write(batch("2")) + spool.write(batch("3")) // «1» выброшена + + assertEquals(2, spool.pendingCount()) + assertEquals("2", spool.peek()?.entries?.single()?.tag) + assertEquals("2", spool.take()?.entries?.single()?.tag) + assertEquals("3", spool.take()?.entries?.single()?.tag) + assertNull(spool.take()) + assertEquals(0, spool.pendingCount()) + } + + @Test + fun `in-memory spool peek does not consume`() { + val spool = InMemoryLogSpool() + spool.write(batch("a")) + assertEquals("a", spool.peek()?.entries?.single()?.tag) + assertEquals("a", spool.peek()?.entries?.single()?.tag) + assertEquals(1, spool.pendingCount()) + } + + @Test + fun `batch json roundtrip`() { + val batch = LogBatch( + "phone", + listOf(LogEntry(1, "a", "x", "phone"), LogEntry(2, "b", "y", "glasses")), + ) + val text = LogBatch.encodeBatch(batch) + assertEquals(batch, LogBatch.decodeBatch(text)) + } + + @Test + fun `batch json decode garbage is null`() { + assertNull(LogBatch.decodeBatch("garbage")) + assertNull(LogBatch.decodeBatch("")) + } +} diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LokiLogSinkTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LokiLogSinkTest.kt new file mode 100644 index 0000000..15e4538 --- /dev/null +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LokiLogSinkTest.kt @@ -0,0 +1,40 @@ +package pw.binom.viewmate.core.log + +import kotlin.test.Test +import kotlin.test.assertEquals + +class LokiLogSinkTest { + + @Test + fun `payload groups by source and labels instance`() { + val entries = listOf( + LogEntry(1_000, "hub", "phone line", "phone"), + LogEntry(2_000, "mc", "glasses line", "glasses"), + LogEntry(3_000, "hub", "another phone", "phone"), + ) + val push = buildLokiPush(entries, "view-mate") + + assertEquals(2, push.streams.size) + + val phoneStream = push.streams.first { it.stream["instance"] == "phone" } + val glassesStream = push.streams.first { it.stream["instance"] == "glasses" } + + assertEquals("view-mate", phoneStream.stream["job"]) + assertEquals(2, phoneStream.values.size) + // timestamp: ms * 1e6, sorted ascending + assertEquals("1000000000", phoneStream.values[0][0]) + assertEquals("hub: phone line", phoneStream.values[0][1]) + assertEquals("3000000000", phoneStream.values[1][0]) + + assertEquals(1, glassesStream.values.size) + assertEquals("2000000000", glassesStream.values[0][0]) + assertEquals("mc: glasses line", glassesStream.values[0][1]) + } + + @Test + fun `blank source falls back to phone`() { + val push = buildLokiPush(listOf(LogEntry(0, "t", "m", "")), "job") + assertEquals(1, push.streams.size) + assertEquals("phone", push.streams[0].stream["instance"]) + } +} diff --git a/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt b/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt new file mode 100644 index 0000000..9fdce1f --- /dev/null +++ b/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt @@ -0,0 +1,68 @@ +package pw.binom.viewmate.core.log + +import java.io.File + +class FileLogSpool( + dir: String, + maxFiles: Int, +) : LogSpool { + private val filesDir: File = File(dir).also { if (!it.exists()) it.mkdirs() } + private val limit = maxFiles + + /** Сериал следующих файлов: продолжение после старейшего существующего. */ + private var seq: Long = currentFiles().mapNotNull { it.seqNumber() }.maxOrNull() ?: 0L + + override fun write(batch: LogBatch) { + seq += 1 + File(filesDir, "$PREFIX$seq.json").writeText(LogBatch.encodeBatch(batch)) + trim() + } + + override fun peek(): LogBatch? { + var file = oldestFile() + while (file != null) { + val batch = decodeQuiet(file) + if (batch != null) return batch + file.delete() // битый файл не должен затыкать очередь + file = oldestFile() + } + return null + } + + override fun take(): LogBatch? { + var file = oldestFile() + while (file != null) { + val batch = decodeQuiet(file) + file.delete() + if (batch != null) return batch + file = oldestFile() + } + return null + } + + override fun pendingCount(): Int = currentFiles().size + + private fun oldestFile(): File? = currentFiles().minByOrNull { it.seqNumber() } + + private fun currentFiles(): List = filesDir + .listFiles { f -> f.isFile && f.name.startsWith(PREFIX) && f.name.endsWith(".json") } + ?.toList() + ?: emptyList() + + private fun File.seqNumber(): Long = + name.substringAfter(PREFIX).removeSuffix(".json").toLongOrNull() ?: 0L + + private fun decodeQuiet(file: File): LogBatch? = + runCatching { LogBatch.decodeBatch(file.readText()) }.getOrNull() + + /** Потолок: выбросить старейшие файлы сверх [limit]. */ + private fun trim() { + val files = currentFiles().sortedBy { it.seqNumber() } + if (files.size <= limit) return + files.take(files.size - limit).forEach { it.delete() } + } + + private companion object { + const val PREFIX = "logspool-" + } +} diff --git a/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt b/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt new file mode 100644 index 0000000..3014843 --- /dev/null +++ b/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt @@ -0,0 +1,16 @@ +package pw.binom.viewmate.core.log + +import java.util.concurrent.locks.ReentrantLock + +actual class LogLock { + private val delegate = ReentrantLock() + + actual fun withLock(action: () -> T): T { + delegate.lock() + return try { + action() + } finally { + delegate.unlock() + } + } +} diff --git a/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt b/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt new file mode 100644 index 0000000..cf1a8af --- /dev/null +++ b/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt @@ -0,0 +1,5 @@ +package pw.binom.viewmate.core.log + +actual class SystemClock : LogClock { + override fun nowMillis(): Long = System.currentTimeMillis() +} diff --git a/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/log/FileLogSpoolTest.kt b/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/log/FileLogSpoolTest.kt new file mode 100644 index 0000000..b2c70a2 --- /dev/null +++ b/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/log/FileLogSpoolTest.kt @@ -0,0 +1,66 @@ +package pw.binom.viewmate.core.log + +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class FileLogSpoolTest { + + private var tempDir: File? = null + + private fun batch(tag: String) = LogBatch("phone", listOf(LogEntry(0, tag, tag, "phone"))) + + private fun freshDir(name: String): File { + val dir = Files.createTempDirectory("logspool-$name").toFile() + tempDir = dir + return dir + } + + @AfterTest + fun cleanup() { + tempDir?.deleteRecursively() + } + + @Test + fun `writes and drains FIFO, survives restart, trims cap`() { + val dir = freshDir("fif") + var spool = FileLogSpool(dir.absolutePath, maxFiles = 2) + spool.write(batch("1")) + spool.write(batch("2")) + spool.write(batch("3")) // «1» выбрасывается по потолку + + assertEquals(2, spool.pendingCount()) + assertEquals("2", spool.peek()?.entries?.single()?.tag) + assertEquals("2", spool.take()?.entries?.single()?.tag) + + // «перезапуск»: новая инстанция видит прихранианные файлы и продолжает сериал + spool = FileLogSpool(dir.absolutePath, maxFiles = 2) + assertEquals(1, spool.pendingCount()) + assertEquals("3", spool.take()?.entries?.single()?.tag) + assertNull(spool.take()) + } + + @Test + fun `corrupt file is skipped without blocking queue`() { + val dir = freshDir("corrupt") + File(dir, "logspool-1.json").writeText("not a json") + val spool = FileLogSpool(dir.absolutePath, 64) + spool.write(batch("a")) + + assertEquals("a", spool.take()?.entries?.single()?.tag) // битый удалён, «a» дошёл + assertNull(spool.take()) + assertEquals(0, spool.pendingCount()) + } + + @Test + fun `empty directory is empty spool`() { + val dir = freshDir("empty") + val spool = FileLogSpool(dir.absolutePath, 64) + assertEquals(0, spool.pendingCount()) + assertNull(spool.peek()) + assertNull(spool.take()) + } +}