5.7 KiB
5.7 KiB
Спецификация 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→ syncavg_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 (ручной сценарий).