VEU.WathBody/1-docs/4-modules/m12-watch-channel/api_specs/03-watch-channel-facade.md

5.7 KiB
Raw Blame History

Спецификация 03: WatchChannel — фасад канала

Источники: module_reqs разд. 1 (фасад, жизненный цикл, решения 4/5), Ф-3.1/3.2, Ф-13.2, инвариант 3 (канал не пишет в БД). Единицы 3, 8.

1. Состояние

enum class WatchStatus { NOT_SELECTED, NO_PERMISSION, BT_OFF,
    SCANNING, CONNECTED, NO_SIGNAL }

data class WatchChannelState(
    val status: WatchStatus,
    val hr: Int?,          // свежий сэмпл (≤10 с) или null
    val battery: Int?,     // последний Battery-событие или null
)

Маппинг статусов Ф-3.1 (цвет — только дублирование 🛡):

WatchStatus Цвет Текст статус-строки
CONNECTED + hr зелёный «часы: связь есть (заряд N %)»
CONNECTED без свежего hr серый «часы: подключены, нет сигнала»
NO_SIGNAL (после потери) красный «часы: нет связи»
SCANNING / CONNECTING серый «часы: поиск…»
NOT_SELECTED серый «часы: не выбраны»
BT_OFF / NO_PERMISSION серый «Bluetooth выключен» / «нет разрешения»

2. Класс

class WatchChannel(
    private val transport: WatchTransport,   // HrBleClient (прод)
    private val store: HrSampleStore,
) {
    val state: StateFlow<WatchChannelState>
    fun bind(workoutStartedAtMs: Long?)  // экран Тренировка (есть дата) / Допуск (null)
    fun unbind()
    fun avgHr(nowMs: Long): Int?         // среднее окна тренировки
    fun freshHr(nowMs: Long, maxAgeMs: Long = 30_000): Int?  // допуск Ф-13.2
}
  • bind: подписка на transport.events; сэмплы → store.add; Battery → state; статусы → state (приоритет блокировок: NO_PERMISSION/BT_OFF > NOT_SELECTED > события транспорта); затем transport.start(address) если адрес задан и разрешения есть; повторный bind — no-op;
  • NOT_SELECTED: адрес пуст (Watch.HrDeviceAddress = "") — транспорт не стартует, статус «не выбраны» (в зале чужие датчики — авто-подключение к первому найденному запрещено, решение 5 гейта 1.3);
  • unbind: transport.stop(); буфер НЕ чистится (среднее нужно до финишированной записи — clear по факту финиша из VM);
  • avgHr: store.average(workoutStartedAtMs, nowMs) — VM передаёт в WorkoutCore.finishWorkout(id, avgHr) (цепочка m09: workout.avg_hr → sync avg_hr; контракт не меняется);
  • freshHr: последний сэмпл ≤30 с — для кнопки «взять с часов» на «Допуске»;
  • инвариант 3: фасад ничего не пишет в LocalDb/sync; единственная точка записи — существующий finishWorkout(avgHr) со стороны VM.

3. Разрешения и адаптер (статусы без транспорта)

Проверки на каждый bind (и на возврат в foreground):

  • 12+: не выданы BLUETOOTH_SCAN+BLUETOOTH_CONNECT → NO_PERMISSION (UI показывает кнопку запроса, спец 04);
  • 8–11: нет ACCESS_FINE_LOCATION → NO_PERMISSION;
  • адаптер выключен → BT_OFF (при включении — интент из спец 01 §2 перезапускает скан).

4. Интеграция (точки m10/m09)

  • VM «Тренировки»: DisposableEffect { bind(workout?.startedAtMs); onDispose { unbind() } }; статус-строка/пульс/линия — state + store.series(lastSetAtMs, now); finishWorkout вызывается с avgHr(now); после финиша store.clear();
  • VM «Допуска»: bind(null) на экране; кнопка «взять с часов» активна при freshHr != null; unbind на выходе;
  • контейнер (WatchBodyApp): watchChannel — singleton; HrBleClient инжектится как WatchTransport (подмена фейком в тестах; будущие транспорты — той же инъекцией, инвариант 6).

5. Настройка

Watch.HrDeviceAddress: String (DataStore, умолчание ""): BLE-адрес выбранного устройства; запись из блока «Часы» в «Настройках» (спец 04); сброс — запись "". Валидация адреса не требуется (несуществующий адрес = вечный SCANNING, читается статусом).

6. Тесты (pure JVM, фейк WatchTransport)

  • переходы: start(SCANNING)→CONNECTED→Sample→state.hr;
  • потеря: CONNECTED→LOST→SCANNING, state «нет связи» → «поиск…»;
  • NOT_SELECTED при пустом адресе — transport.start не вызывается;
  • NO_PERMISSION/BT_OFF перекрывают события транспорта;
  • устаревание hr (>10 с) — hr=null при CONNECTED;
  • avgHr по окну тренировки (сэмплы до старта исключены); freshHr 30 с — граница;
  • unbind вызывает transport.stop; clear после финиша — из VM (ручной сценарий).