6.2 KiB
6.2 KiB
Спецификация 01: WatchTransport + HrBleClient (BLE Heart Rate Service)
Источники: module_reqs разд. 1.1/1 (транспорт-интерфейс, решение 6 — мультичасовая инфраструктура), ОВ-3 errata, Ф-3.1–3.2. Единица 1.
1. Транспорт-интерфейс (платформо-независимый)
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<TransportEvent>
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);
- подписка notifications (если CCC поддерживается); нет сервиса —
событие
- потеря:
onConnectionStateChange(DISCONNECTED)послеCONNECTED→LOST→ повторный скан с паузами 1/5/30 с (далее каждые 30 с), пока не вызванstop(); - выключение Bluetooth:
BT_OFF; системный интент включения (receiver при активномstart) → автоматический переход вSCANNING; stop()закрывает GATT и скан; повторныйstartначинается сSCANNING.
3. Разбор пакета 0x2A37 — HrPacketParser (чистая функция)
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, не блок.