# Спецификация 03: WatchChannel — фасад канала Источники: module_reqs разд. 1 (фасад, жизненный цикл, решения 4/5), Ф-3.1/3.2, Ф-13.2, инвариант 3 (канал не пишет в БД). Единицы 3, 8. ## 1. Состояние ```kotlin 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. Класс ```kotlin class WatchChannel( private val transport: WatchTransport, // HrBleClient (прод) private val store: HrSampleStore, ) { val state: StateFlow 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 (ручной сценарий).