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