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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+140
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user