# Спецификация 01: WatchTransport + HrBleClient (BLE Heart Rate Service) Источники: module_reqs разд. 1.1/1 (транспорт-интерфейс, решение 6 — мультичасовая инфраструктура), ОВ-3 errata, Ф-3.1–3.2. Единица 1. ## 1. Транспорт-интерфейс (платформо-независимый) ```kotlin enum class TransportStatus { IDLE, // не запущен (stop/до start) SCANNING, // ищем устройство CONNECTING, // gatt-подключение/обнаружение сервисов CONNECTED, // подписка активна, сэмплы идут LOST, // связь была и потеряна — автопереподключение BT_OFF, // Bluetooth выключен NO_PERMISSION, // нет требуемых разрешений (см. §6) } sealed interface TransportEvent { data class Status(val status: TransportStatus) : TransportEvent data class Sample(val bpm: Int, val atMs: Long) : TransportEvent data class Battery(val percent: Int) : TransportEvent } interface WatchTransport { val events: kotlinx.coroutines.flow.SharedFlow fun start(deviceAddress: String) // адрес выбранного устройства fun stop() } ``` - интерфейс не содержит ничего BLE/Garmin-специфичного: будущие транспорты (CIQ 5+ companion, ANT+) реализуют его без правок фасада и UI (инвариант 6); - события `Sample` приходят только в `CONNECTED`; `Battery` — опционально (не все устройства exposing Battery Service); - `LOST` сопровождается автоматическим повторным `SCANNING` — фасад показывает «потерю», затем снова «скан». ## 2. BLE-реализация `HrBleClient` Только стандартные профили GATT: Heart Rate Service `0000180d-0000-1000-8000-00805f9b34fb` (Measurement `0x2A37`, indications), Battery Service `0x180F` (Level `0x2A19`). Никаких фильтров по имени/производителю — любое устройство со стандартной трансляцией пульса (часы/строп) совместимо. - **скан**: `ScanFilter` по UUID сервиса 0x180D; найден адрес `deviceAddress` → подключение; не найден за 60 с → остаёмся в `SCANNING` (продолжаем искать, статус не меняется); - **подключение**: `connectGatt(autoConnect = false)`; при первой ошибке — повтор с `autoConnect = true` (системное переподключение); обнаружение сервисов → подписка indications на 0x2A37; - **батарея**: если сервис 0x180F обнаружен — чтение 0x2A19 однократно + подписка notifications (если CCC поддерживается); нет сервиса — событие `Battery` не приходит вовсе (прочерк, Ф-3.1); - **потеря**: `onConnectionStateChange(DISCONNECTED)` после `CONNECTED` → `LOST` → повторный скан с паузами 1/5/30 с (далее каждые 30 с), пока не вызван `stop()`; - **выключение Bluetooth**: `BT_OFF`; системный интент включения (receiver при активном `start`) → автоматический переход в `SCANNING`; - `stop()` закрывает GATT и скан; повторный `start` начинается с `SCANNING`. ## 3. Разбор пакета 0x2A37 — `HrPacketParser` (чистая функция) ```kotlin object HrPacketParser { data class HrPacket(val bpm: Int, val energyPresent: Boolean, val rrCount: Int) fun parse(bytes: ByteArray): HrPacket? } ``` - байт 0 — флаги: бит 0 — формат пульса (0 → uint8, 1 → uint16); бит 1 — есть Energy Expended (uint16, пропускается); бит 2 — есть RR-интервалы (n × uint16 в 1/1024 с, пропускаются); - пульс вне 30–250 — пакет игнорируется (склейка/глюки датчика); `null` — слишком короткий пакет; - прочие флаги (зарезервированные биты) не разбираются. ## 4. Потоки и контекст - колбэки BluetoothGatt доставляются на главном потоке; `events` — `SharedFlow` ( replay 0, extraBufferCapacity 16 ), эмит без приостановки (`tryEmit`); фасад — единственный слушатель; - класс — Android-зависимый (только обёртка API); логика разбора — в `HrPacketParser` (JVM), выбора пауз переподключения — `ReconnectPolicy` (JVM: nextDelayMs(попытка) → 1000, 5000, 30000, 30000…). ## 5. Тесты (pure JVM) - `HrPacketParser`: uint8/uint16, с Energy, с RR, с обоими, короткий пакет → null, bpm 0/29/251 → пропуск, bpm 30/250 — границы приняты; - `ReconnectPolicy`: последовательность 1/5/30/30 с; - интерфейсные события фасада — на фейке `WatchTransport` (спец 03). ## 6. Разрешения (статусы NO_PERMISSION/BT_OFF выставляет фасад) | Android | Скан/подключение | Runtime | |---|---|---| | 12+ (31+) | `BLUETOOTH_SCAN` (neverForLocation) + `BLUETOOTH_CONNECT` | да | | 8–11 (26–30) | `BLUETOOTH` + `BLUETOOTH_ADMIN` (манифест) + `ACCESS_FINE_LOCATION` | да (локация) | Манифест: перечислены обе группы (`ACCESS_FINE_LOCATION` без `maxSdkVersion` не ограничиваем — используется и другими частями; `usesPermissionFlags="neverForLocation"` на SCAN). Запрос — UI (спец 04); отказ → `NO_PERMISSION`, не блок.