feat: управление яркостью/WiFi очков через Mercury + накопленные доработки

- 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
This commit is contained in:
2026-08-29 23:06:52 +03:00
parent 62d2f06797
commit 6cd4a8de43
40 changed files with 1409 additions and 203 deletions
@@ -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 {
@@ -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(
@@ -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
}
}
@@ -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)
}
+4 -1
View File
@@ -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")
+1
View File
@@ -14,6 +14,7 @@
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
@@ -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
@@ -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<String> =
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)
}
@@ -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<LogEntry>) -> 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)
@@ -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)
}
@@ -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<MyWifiInfo> = 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)
}
}
@@ -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 = "тулсет,тулы,умеешь"
}
@@ -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()
}
}
}
override fun recognize(samples: FloatArray): String = recognizer.recognize(samples, 16000)
}
@@ -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)
}
}
@@ -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()
}
}
}
@@ -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())
}
@@ -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<FileDownloadState, Int>,
glasses: Pair<FileDownloadState, Int>,
): 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<FileDownloadState, Int>,
glasses: Pair<FileDownloadState, Int>,
state: Pair<FileDownloadState, Int>,
) {
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)
}
}
}
@@ -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)
}
}
}
}
}
}
}
}
}
@@ -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
@@ -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<File> = 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-"
}
}
@@ -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 <T> withLock(action: () -> T): T {
delegate.lock()
return try {
action()
} finally {
delegate.unlock()
}
}
}
@@ -0,0 +1,5 @@
package pw.binom.viewmate.core.log
actual class SystemClock : LogClock {
override fun nowMillis(): Long = System.currentTimeMillis()
}
@@ -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)
}
@@ -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<LogEntry>()
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<LogEntry>) {
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
}
}
@@ -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)
@@ -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<LogEntry>,
) {
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()
}
}
@@ -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
}
}
@@ -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 <T> withLock(action: () -> T): T
}
@@ -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
}
@@ -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<LogBatch>()
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
}
@@ -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<String, String>, val values: List<List<String>>)
@Serializable
internal data class LokiPush(val streams: List<LokiStream>)
internal fun buildLokiPush(entries: List<LogEntry>, 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()
}
@@ -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,
@@ -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<LogEntry>) : GlassesToHost
@@ -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<LogBatch>()
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())
}
}
@@ -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(""))
}
}
@@ -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"])
}
}
@@ -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<File> = 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-"
}
}
@@ -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 <T> withLock(action: () -> T): T {
delegate.lock()
return try {
action()
} finally {
delegate.unlock()
}
}
}
@@ -0,0 +1,5 @@
package pw.binom.viewmate.core.log
actual class SystemClock : LogClock {
override fun nowMillis(): Long = System.currentTimeMillis()
}
@@ -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())
}
}