m12-watch-channel (итерация 5): live-пульс с Fenix 6X — errata ОВ-3: companion-канал Connect IQ нереализуем на fenix 6X (CIQ ≤4.1.7, Communications в полях данных с API 5.0), транспорт = штатная трансляция «Broadcast Heart Rate» (BLE Heart Rate Service, односторонний, без GCM/ANT-железа) за интерфейсом WatchTransport — мультичасовая инфраструктура (указание владельца: любые часы со стандартной трансляцией, CIQ 5+/ANT+ — будущие реализации); таймер отдыха на часах автономный (Ф-2.4 errata, настройка поля, умолчание Rest.DefaultSec); ядро: HrBleClient (скан 0x180D→indications 0x2A37, оба поколения GATT-колбэков, батарея 0x180F опц., автопереподключение 1/5/30 с) + HrPacketParser + HrSampleStore + фасад WatchChannel (статусы/avgHr/freshHr/series, в БД не пишет) + WatchFormat + DeviceScanner; UI: статус-строка и блок пульса 44sp+зона+Canvas-линия последнего подхода на «Тренировке», finishWorkout(avgHr)→sync avg_hr, «Взять с часов» (≤30 с) на «Допуске», блок «Часы» (скан 15 с/выбор/забыть/инструкция) в «Настройках», 5 BLE-разрешений; unit 89/89 (m12 +26), APK 57,3 МБ; errata НД: requirements Ф-2.4/Ф-3.2/ОВ-реестр№3, tech-req 3п.3+7п.1, architecture 1.1/1.2/3/1.4; BLE на устройстве — приёмка владельца
This commit is contained in:
parent
28445f28b9
commit
d4867acefd
|
|
@ -46,8 +46,8 @@ JSON-тела (версии программ, факты .fit) — `jsonb` вн
|
|||
| m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ✅ (2026-10-19: Room 9 таблиц + WorkoutCore + SyncWorker/ApiClient + DataStore; APK 4,2 МБ, unit 18/18; смок sync 5 сущностей против живого сервера пройден, errata client_guid-UUID + guid-колонки daily_log/profile) |
|
||||
| m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ✅ (2026-10-19: 8 экранов+VM, Compose Material 3 тёмная, unit 36/36, APK 12 МБ, живой смок контура программы; errata контрактов camelCase/flat program_version/sex m-f; запуск на устройстве — приёмка владельца) |
|
||||
| m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ✅ (2026-10-19: парсер Ф-1.2 + VoskEngine с грамматикой/fallback + ModelStore (filesDir, не в APK) + executor через WorkoutCore 🛡 + TTS/сигналы + Mic-кнопка/RECORD_AUDIO + блок настроек; unit 63/63, APK 57 МБ; живой URL модели 200 OK; распознавание на устройстве — приёмка владельца, КУ-1 вручную) |
|
||||
| m12-watch-channel | `WatchChannel`: companion-канал Connect IQ (прототип ОВ-3 — итерация 1, чек-лист 7.1) | 5 | ⬜ |
|
||||
| m13-fenix-data-field | `FenixDataField` (Monkey C, таргет `fenix6x`): пульс/зоны/отдых | 5 | ⬜ |
|
||||
| m12-watch-channel | `WatchChannel`: live-пульс с Fenix 6X на телефон — errata ОВ-3 (2026-10-19): companion-канал Connect IQ нереализуем на fenix 6X (CIQ ≤ 4.1.7, Communications в полях данных — с API 5.0); транспорт — штатная трансляция «Broadcast Heart Rate», приём BLE Heart Rate Service (односторонний); таймер отдыха на часах автономный (Ф-2.4 errata) | 5 | ✅ (2026-10-19: гейты 1.3/2.4/3.4 утверждены; errata ОВ-3 — транспорт BLE «Broadcast Heart Rate» за интерфейсом `WatchTransport` (мультичасовая инфраструктура, указание владельца); пульс/заряд/статус на «Тренировке», «Взять с часов» на допуске, `avg_hr` при финише → sync; unit 89/89 (m12 +26), APK 57,3 МБ; волна errata НД внесена (Ф-2.4/Ф-3.2/tech-req/architecture); живой BLE/разрешения — приёмка владельца; НЕ закоммичен) |
|
||||
| m13-fenix-data-field | `FenixDataField` (Monkey C): пульс/зоны/автономный таймер отдыха; мультитаргет-сборка по моделям/разрешениям (vision 1.1, Ф-2.8; якорь fenix6x 280×280 — сборка под другие часы не должна усложняться, указание владельца 2026-10-19) | 5 | ⬜ |
|
||||
|
||||
Прототипы чек-листа шага 7 (tech-requirements разд. 7) выполняются в
|
||||
своих итерациях; ОВ-3 (companion-канал) — прототип в итерации 1 параллельно
|
||||
|
|
|
|||
|
|
@ -186,10 +186,12 @@ Garmin штатно — Ф-2.5, Ф-2.6). Код поля данных перен
|
|||
3. Границы подхода: поле считает подход открытым от штатного события
|
||||
нового подхода (Strength Set) до события Lap; метрики подхода
|
||||
обнуляются на границе.
|
||||
4. Вибрация: однократная лёгкая — конец таймера отдыха, переданного
|
||||
с телефона (если связь есть); двойная — выход пульса за красную
|
||||
зону. При отсутствии связи с телефоном вибраций отдыха нет —
|
||||
часы не блокируются (не зависимость от телефона).
|
||||
4. Вибрация: однократная лёгкая — конец автономного таймера отдыха
|
||||
поля (длительность — настройка поля в Garmin Connect Mobile,
|
||||
умолчание `Rest.DefaultSec`); двойная — выход пульса за красную
|
||||
зону. Передача длительности с телефона — v2 (errata ОВ-3 от
|
||||
2026-10-19: канал часов односторонний, часы → телефон); часы не
|
||||
блокируются (не зависимость от телефона).
|
||||
5. Кнопка Lap — штатная: фиксирует конец подхода/круга в записи
|
||||
активности (требование — метки Lap попадают в .fit, КУ-2).
|
||||
6. Кнопка Start/Stop — штатная пауза/стоп активности; поле не требует
|
||||
|
|
@ -208,8 +210,12 @@ Garmin штатно — Ф-2.5, Ф-2.6). Код поля данных перен
|
|||
связь недоступна, приложение не блокируется), заряд часов (если
|
||||
передаётся), статус сети (онлайн/офлайн).
|
||||
2. Пульс: крупное текущее значение + линия за последний подход;
|
||||
источник — канал телефон ↔ часы (открытый вопрос 3). Если связи
|
||||
нет — прочерк; запись голосом работает (Ф-1.1).
|
||||
источник — штатная трансляция пульса часов «Broadcast Heart
|
||||
Rate», приём по BLE Heart Rate Service (errata ОВ-3 от
|
||||
2026-10-19: companion-канал Connect IQ на fenix 6X недоступен
|
||||
для полей данных, CIQ ≤ 4.1.7; транспорт платформо-независимый —
|
||||
любые часы со стандартной трансляцией). Если связи нет —
|
||||
прочерк; запись голосом работает (Ф-1.1).
|
||||
3. Таймер отдыха: обратный отсчёт, фон — красный (идёт отдых),
|
||||
жёлтый (последние 10 с), зелёный (время начинать); старт — командой
|
||||
«Отдых S» или кнопкой (умолчание `Rest.DefaultSec`).
|
||||
|
|
@ -591,7 +597,7 @@ Garmin штатно — Ф-2.5, Ф-2.6). Код поля данных перен
|
|||
|---|---|---|---|
|
||||
| 1 | Формула максимальной ЧСС | Умолчание для зон (Ф-6.2): 220 − возраст (классика) или Танака (208 − 0,7×возраст, точнее для пожилых); можно — ручной ввод `Hr.MaxBpm` | Закрыт шагом 5 |
|
||||
| 2 | Офлайн-движок распознавания | Vosk (модель ~50 МБ, ru) против sherpa-onnx; грамматика цифр шумного зала; влияет на точность КУ-1. Кандидаты проверяются прототипом | Закрыт шагом 5 |
|
||||
| 3 | Канал телефон ↔ часы | Connect IQ companion (Communications): что передаётся реально (пульс live, зоны, таймер отдыха, заряд); ограничения канала определяют Ф-2.4, Ф-3.2, Ф-6.3. Прототип на шаге 4 | Закрыт шагом 4 |
|
||||
| 3 | Канал телефон ↔ часы | Connect IQ companion (Communications): что передаётся реально (пульс live, зоны, таймер отдыха, заряд); ограничения канала определяют Ф-2.4, Ф-3.2, Ф-6.3. Прототип на шаге 4 | Закрыт шагом 4; **errata 2026-10-19**: companion-канал на fenix 6X нереализуем (CIQ ≤ 4.1.7) — транспорт = штатная BLE-трансляция пульса, канал односторонний (Ф-3.2) |
|
||||
| 4 | Расписание `fetch` | cron/systemd-таймер на сервере против внутреннего планировщика; предпочтителен внешний планировщик | Закрыт шагом 5 |
|
||||
| 5 | Способ сопоставления тренировок телефон ↔ .fit | Окно ± 15 минут (Ф-8.4) против точной метки начала из sync | ✅ Закрыт в этом документе (раздел 4, Ф-8.4) |
|
||||
| 6 | Доступ в веб-интерфейс | ✅ Закрыт изменением vision 1.2: веб — административная консоль владельца, вход по логину и паролю (раздел 4, Ф-9); атлеты — только телефон | ✅ Закрыт (изменение vision 1.2) |
|
||||
|
|
|
|||
|
|
@ -25,10 +25,13 @@
|
|||
код, сборки под целевые модели Garmin — конфигурациями (изменение
|
||||
vision 1.1); перечень моделей — открытый вопрос 8 требований.
|
||||
|
||||
Связь с телефоном — companion-канал Connect IQ (`WatchChannel`,
|
||||
компонент телефона): live-пульс, зоны, таймер отдыха передаются
|
||||
в объёме возможностей канала (открытый вопрос 3); отсутствие связи
|
||||
ничего не ломает — часы автономны.
|
||||
Связь с телефоном — штатная трансляция пульса «Broadcast Heart
|
||||
Rate», приём на телефоне по BLE (`WatchChannel`, компонент
|
||||
телефона; errata ОВ-3 от 2026-10-19: companion-канал Connect IQ
|
||||
на fenix 6X недоступен для полей данных — CIQ ≤ 4.1.7): live-пульс
|
||||
на телефоне, канал односторонний (часы → телефон), таймер отдыха
|
||||
на часах автономный; отсутствие связи ничего не ломает — часы
|
||||
автономны.
|
||||
|
||||
### 1.2. Телефон атлета — **Phone.WatchBody** (Android, Kotlin)
|
||||
|
||||
|
|
@ -48,7 +51,10 @@ vision 1.1); перечень моделей — открытый вопрос 8
|
|||
(`breaks`), очередь.
|
||||
- `SyncQueue` — исходящие записи с `client_guid`, пакеты ≤ 50,
|
||||
идемпотентная отправка `POST /api/v1/sync`; тихие баннеры статуса.
|
||||
- `WatchChannel` — companion-канал Connect IQ.
|
||||
- `WatchChannel` — приём live-пульса с часов: BLE Heart Rate
|
||||
Service за интерфейсом `WatchTransport` (платформо-независимый —
|
||||
любые часы со стандартной трансляцией); статус связи, заряд,
|
||||
средний пульс тренировки.
|
||||
|
||||
🛡 Офлайн-first: сеть стоит только на пути `SyncQueue`; все остальные
|
||||
компоненты работают без сети (requirements, раздел 5).
|
||||
|
|
@ -76,7 +82,8 @@ vision 1.1); перечень моделей — открытый вопрос 8
|
|||
|
||||
- **Garmin Connect** — облако: источник `.fit` (забор `FitFetcher`
|
||||
по расписанию — внешний планировщик, открытый вопрос 4).
|
||||
- **Garmin (устройства)** — запись `.fit` на часах, companion-канал.
|
||||
- **Garmin (устройства)** — запись `.fit` на часах, трансляция
|
||||
пульса (BLE/ANT+).
|
||||
|
||||
## 2. Поток цикла обработки
|
||||
|
||||
|
|
@ -113,7 +120,7 @@ vision 1.1); перечень моделей — открытый вопрос 8
|
|||
| Граница | Контракт | Открытые вопросы |
|
||||
|---|---|---|
|
||||
| Телефон ↔ сервер | REST/HTTPS, `X-Api-Key` (атлет), `client_guid`-идемпотентность, пакеты ≤ 50; формат записей — JSON (документ форматов) | — (спецификация — шаг 5) |
|
||||
| Телефон ↔ часы | companion-канал Connect IQ: live-пульс, зоны, таймер отдыха; деградация без связи | ОВ-3 (шаг 4) |
|
||||
| Телефон ↔ часы | штатная трансляция пульса «Broadcast Heart Rate» → BLE-приём (`WatchTransport`): live-пульс, заряд; односторонний канал; деградация без связи | ОВ-3 (закрыт errata 2026-10-19) |
|
||||
| Сервер ↔ Garmin Connect | неофициальный клиент, учётка владельца; деградация — папка импорта `--mode import` | — |
|
||||
| Сервер ↔ LLM (v2) | structured output, валидатор, офлайн-fallback на правила (дизайн LLM-планирования) | до v2 |
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
| 1. Максимальная ЧСС | Формула Танаки: `HRmax = 208 − 0,7 × возраст`; ручной ввод `Hr.MaxBpm` перекрывает расчёт | Точнее для 45+ (профиль пользователя); ручной ввод — окончательная истина |
|
||||
| 2. Офлайн-STT | **Vosk** (малая русская модель ~50 МБ) + узкая грамматика команд (Ф-1.2: числа и слова «подход/вес/отдых/секунд/РПЭ» — «РПЭ 8» ставит RPE последнему
|
||||
подходу, Ф-13.3); TTS-подтверждения — системный Android TTS | Работа без сети, малый словарь → устойчивость к шуму; sherpa-onnx — запасная кандидатура, если точность < КУ-1 на контрольной тренировке |
|
||||
| 3. Канал телефон ↔ часы | Connect IQ **Communications SDK** (companion-канал через приложение Garmin Connect Mobile на телефоне). Передаётся: текущий пульс (≈1 Гц), зона, таймер отдыха (секунды до конца), заряд часов. Ограничение: на телефоне должно быть установлено и залогинено Garmin Connect Mobile; канал может не доставлять — обе стороны деградируют молча (Ф-2.4, Ф-3.2) | Единственный штатный канал сторонних приложений Garmin; проверка объёма передачи — прототипом в итерации 1 шага 7 (критерий: пульс и таймер доходят при экране часов активным) |
|
||||
| 3. Канал телефон ↔ часы | **Штатная трансляция пульса часов «Broadcast Heart Rate», приём по BLE** — стандартный Heart Rate Service 0x180D (errata ОВ-3 от 2026-10-19: companion-канал Connect IQ на fenix 6X нереализуем — CIQ ≤ 4.1.7, `Communications` в полях данных только с API 5.0). Передаётся: текущий пульс (≈1 Гц), заряд часов (Battery Service, если транслируется). Канал односторонний (часы → телефон): таймер отдыха на часах автономный (настройка поля, умолчание `Rest.DefaultSec`; Ф-2.4 errata). Транспорт скрыт интерфейсом `WatchTransport` (мультичасовая инфраструктура, указание владельца 2026-10-19: любые часы со стандартной BLE-трансляцией работают без изменений кода; CIQ 5+/ANT+ — будущие реализации интерфейса). Garmin Connect Mobile не требуется; канал может не доставлять — обе стороны деградируют молча (Ф-2.4, Ф-3.2) | Проверка на устройстве владельца в m12: пульс доходит на телефон при активном экране часов |
|
||||
| 4. Расписание fetch | Внешний планировщик ОС: systemd timer (Linux-сервер) / Планировщик задач (Windows) вызывает `--mode fetch`; внутри сервера планировщика нет. Интервал вызова — 15 минут (параметр планировщика ОС, в код не входит); вместе с лагом синхронизации часов с Garmin Connect укладывается в БЦ-5 (архив ≤ 30 минут после тренировки) | Проще, надёжнее, не дублирует средства ОС |
|
||||
| 10. Источник .fit атлетов без учётки владельца | v1 — только папка импорта (`--mode import`); `fetch` работает под единственной учёткой владельца; собственные учётки атлетов в конфиге — кандидат v2. Уточнение владельца: атлет регистрируется на телефоне (имя), привязка — по uuid клиентского приложения | Не раздувать v1; деградация уже работает (решение владельца О-1-А, З-19) |
|
||||
| 11. Импорт пакета программы | `--mode import` принимает `program-import.json` (формат — `program-import-format.md`): пакет маппится на `src.program` (`external_id` UNIQUE) и `src.program_version.body`; повторный импорт с тем же `external_id` — новая версия `program_version`; секция `profile` пакета в v1 не переносится — Safety-порогами управляет только конфиг `Safety.*` | Пакет — механизм эксплуатации (уточнение владельца); иначе путь доставки программы атлету в v1 не закрыт (ревью №3, О-4-Б) |
|
||||
|
|
@ -226,7 +226,7 @@
|
|||
|
||||
## 7. Чек-лист шага 7 (проверки прототипами)
|
||||
|
||||
1. Companion-канал: пульс/таймер доходят на телефон (ОВ-3) — итерация 1.
|
||||
1. Канал часов: пульс доходит на телефон при активном экране часов (ОВ-3; errata 2026-10-19 — транспорт BLE-трансляция «Broadcast Heart Rate», таймер отдыха автономный на часах — m13) — итерация 5 (m12).
|
||||
2. Логин Garmin Connect из C# (FitFetcher) — итерация 2.
|
||||
3. Vosk: точность команд в шумном зале ≥ КУ-1 — итерация 2.
|
||||
4. Сопоставление телефон ↔ .fit на реальной тренировке — итерация 3.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
name: m12-watch-channel
|
||||
description: WatchChannel (BLE Heart Rate Service со штатной трансляции Fenix 6X) — live-пульс на экране тренировки/допуска Phone.WatchBody, статус связи, avg_hr
|
||||
---
|
||||
|
||||
# m12-watch-channel
|
||||
|
||||
Live-пульс с часов на телефоне `Phone.WatchBody`: часы Fenix 6X
|
||||
транслируют пульс штатной «Broadcast Heart Rate», телефон принимает
|
||||
по BLE (стандартный Heart Rate Service 0x180D/0x2A37 — companion-
|
||||
канал Connect IQ на fenix 6X недоступен для полей данных, errata
|
||||
ОВ-3). Канал односторонний и «мягкий»: нет связи — прочерки,
|
||||
приложение не блокируется (🛡).
|
||||
|
||||
Состав: `HrBleClient` (скан/подключение/indications/батарея),
|
||||
`HrSampleStore` (буфер: текущее/среднее/линия последнего подхода),
|
||||
`WatchChannel` (фасад-статус), статус-строка и пульс на экране
|
||||
«Тренировка» (m10), «взять с часов» на «Допуске», блок «Часы»
|
||||
в «Настройках», `finishWorkout(avgHr)` (m09). Live-сэмплы в БД
|
||||
не пишутся; таймер отдыха на часах автономный (m13).
|
||||
|
||||
- Требования: [module_reqs.md](module_reqs.md)
|
||||
- План работ: [readme.md](readme.md)
|
||||
- Спецификации: [api_specs/](api_specs/)
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
# Спецификация 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<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);
|
||||
- **потеря**: `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`, не блок.
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
# Спецификация 02: HrSampleStore — буфер пульса (чистый JVM)
|
||||
|
||||
Источники: Ф-3.2 (текущее значение + линия последнего подхода),
|
||||
module_reqs разд. 1 (буфер, устаревание >10 с, решение 4 — только
|
||||
память). Единица 2.
|
||||
|
||||
## 1. Интерфейс
|
||||
|
||||
```kotlin
|
||||
class HrSampleStore {
|
||||
fun add(bpm: Int, atMs: Long) // сэмпл транспортом
|
||||
fun current(nowMs: Long, staleAfterMs: Long = 10_000): Int?
|
||||
fun average(fromMs: Long, toMs: Long): Int? // среднее за окно
|
||||
fun series(fromMs: Long, toMs: Long): List<Pair<Long, Int>> // (atMs, bpm)
|
||||
fun clear() // сброс (новая тренировка)
|
||||
val lastSampleAtMs: Long?
|
||||
}
|
||||
```
|
||||
|
||||
- время — абсолютные миллисекунды (`System.currentTimeMillis()`
|
||||
передаётся вызывающим; в тестах — фикссированные значения);
|
||||
- хранение: весь список сэмплов сессии (`ArrayList<Pair<Long, Int>>`)
|
||||
— при 1 Гц и 3-часовой тренировке ~10⁴ записей, деградации
|
||||
памяти нет; среднее считается инкрементально по окну проходом
|
||||
(список короткий), без накопления суммы всей сессии;
|
||||
- `current`: последний сэмпл, если `nowMs − atMs ≤ staleAfterMs`,
|
||||
иначе `null` (прочерк, «нет данных»);
|
||||
- `average`: арифметическое `Int` (округление вниз) по сэмплам
|
||||
окна `[fromMs, toMs]`; пусто → `null`; `toMs` обычно «сейчас»;
|
||||
- `series` — сэмплы окна в порядке поступления (линия последнего
|
||||
подхода: `fromMs = время последнего logSet`, толщина линии —
|
||||
один сэмпл = одна точка);
|
||||
- значения bpm вне 30–250 в буфер не попадают (отфильтрованы
|
||||
парсером, спец 01 §3 — двойная защита: `add` тоже игнорирует
|
||||
вне диапазона).
|
||||
|
||||
## 2. Связь с тренировкой
|
||||
|
||||
- окно средней — от старта тренировки (`workout.startedAt`) до
|
||||
финиша; сэмплы вне окна (канал работал до старта — экран
|
||||
«Допуск») в среднее не входят;
|
||||
- `clear()` вызывается фасадом при `unbind` после финишированной
|
||||
тренировки; между тренировками буфер накапливается только при
|
||||
открытом экране «Допуск» (для «взять с часов», спец 03 §4).
|
||||
|
||||
## 3. Тесты (pure JVM)
|
||||
|
||||
- фильтрация диапазона при `add`;
|
||||
- `current`: свежий / устаревший (10 001 мс) / пустой буфер;
|
||||
- `average`: пусто → null; одно значение; окно вырезает сэмплы
|
||||
до/после; округление вниз (127,5 → 127);
|
||||
- `series`: порядок и граница окна включительно;
|
||||
- `clear` обнуляет всё.
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
# Спецификация 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
|
||||
(ручной сценарий).
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
# Спецификация 04: UI — «Тренировка», «Допуск», «Настройки» (интеграция m10)
|
||||
|
||||
Источники: Ф-3.1–3.3, Ф-13.2, ui-ux разд. 2, module_reqs разд. 2,
|
||||
решения 3/5/6. Единицы 4–7. Все тексты дублируют цвет (🛡).
|
||||
|
||||
## 1. Экран «Тренировка» (замена заглушек WorkoutScreen:380/258)
|
||||
|
||||
### Статусная строка (Ф-3.1)
|
||||
|
||||
- слева направо: «часы: <статус> (заряд N %)» · «пульс: <bpm|—>» ·
|
||||
«сеть: онлайн/офлайн» (сеть — как в m10);
|
||||
- тексты и цвета — по таблице спец 03 §1; зелёный/красный/серый
|
||||
— только дублирование текстом;
|
||||
- заряд опускается (с прочерком) при `battery == null` —
|
||||
формулировка «часы: связь есть», без «заряд —» (решение 3).
|
||||
|
||||
### Блок пульса (Ф-3.2)
|
||||
|
||||
- текущее значение — крупно (≥40 sp) в существующей зоне статуса,
|
||||
окраска по зонам Танака (`ZoneCalculator` m10, `Hr.MaxBpm`;
|
||||
границы — те же, что на «Профиле»): зона 1 recovery <50 % синий,
|
||||
зона 2 лёгкая 50–70 % зелёный, зона 3 средняя 70–85 % жёлтый,
|
||||
зона 4 высокая >85 % красный — рядом с числом название зоны
|
||||
словами, как на «Профиле» m10 («recovery»/«лёгкая»/«средняя»/
|
||||
«высокая») 🛡;
|
||||
- под значением — линия последнего подхода: Canvas по
|
||||
`store.series(lastSetAtMs, now)`, обновление ~1 Гц, без сеток;
|
||||
пусто/устарело — прочерк «—» вместо линии;
|
||||
- `lastSetAtMs` — время последнего подхода текущей тренировки
|
||||
(VM из `setsFlow`, при отсутствии подходов — старт тренировки);
|
||||
- нет связи/не выбраны/нет разрешения — значение «—», блок
|
||||
сворачивается в статус-строку; голос и ручной ввод не зависят
|
||||
(Ф-1.1, Ф-3.2).
|
||||
|
||||
### Финиш
|
||||
|
||||
- «Завершить» → `finishWorkout(id, avgHr = channel.avgHr(now))`;
|
||||
после записи `store.clear()`; при null среднего — как в m09
|
||||
(`avg_hr` не отправляется).
|
||||
|
||||
## 2. Экран «Допуск» (Ф-13.2)
|
||||
|
||||
- под полем «Пульс сидя» — вторичная кнопка «Взять с часов»:
|
||||
активна при `freshHr(now) != null` (свежий сэмпл ≤30 с),
|
||||
нажатие подставляет значение в поле (пикером остаётся правка);
|
||||
нет сэмпла — кнопка неактивна с подписью причины («часы не
|
||||
связаны» / «нет свежего значения»);
|
||||
- экран держит канал открытым (`bind(null)`), для допуска
|
||||
достаточно пульса покоя — атлет включает трансляцию заранее
|
||||
(инструкция в «Настройках»).
|
||||
|
||||
## 3. Экран «Настройки» — блок «Часы»
|
||||
|
||||
По образцу блока «Распознавание речи» (m11):
|
||||
|
||||
- строка статуса канала (текст из спец 03 §1);
|
||||
- «Найти часы» — скан 15 с по UUID 0x180D (запрашивает разрешения
|
||||
при необходимости), список найденных: имя + адрес, тап — выбор
|
||||
(запись `Watch.HrDeviceAddress`, индикатор «выбрано»);
|
||||
- «Забыть часы» — сброс адреса (`""` → NOT_SELECTED);
|
||||
- инструкция включения трансляции — платформо-нейтральный текст:
|
||||
«Включите на часах трансляцию пульса по Bluetooth» +
|
||||
расширяемый справочник производителей (v1 — один пункт:
|
||||
Garmin: «виджет пульса → удержание MENU → Heart Rate Options →
|
||||
Broadcast During Activity = Вкл»); справочник — `map` в коде
|
||||
UI (не БД), добавление производителя — правкой одного списка;
|
||||
- никаких маркателей конкретных моделей в логике.
|
||||
|
||||
## 4. Разрешения (runtime)
|
||||
|
||||
- экраны «Тренировка»/«Допуск»/«Настройки»: при статусе
|
||||
NO_PERMISSION — строка «Нет разрешения Bluetooth» + кнопка
|
||||
«Разрешить» → системный диалог
|
||||
(12+: `BLUETOOTH_SCAN`+`BLUETOOTH_CONNECT` вместе; 8–11:
|
||||
`ACCESS_FINE_LOCATION` с пояснением «для поиска часов»);
|
||||
«Больше не спрашивать» — кнопка ведёт в системные настройки
|
||||
приложения (проверка после возврата);
|
||||
- отказ не блокирует экраны (🛡 инвариант 2): статус-строка
|
||||
показывает причину, остальное работает.
|
||||
|
||||
## 5. Манифест
|
||||
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
```
|
||||
|
||||
(`BLUETOOTH_CONNECT` — без `neverForLocation`, только SCAN.)
|
||||
|
||||
## 6. Тесты (pure JVM)
|
||||
|
||||
- форматирование статус-строки: все WatchStatus × батарея
|
||||
есть/нет × пульс есть/устарел (тексты из спец 03 §1, заряд
|
||||
опускается при null);
|
||||
- зона пульса → слово-зона и цветовой токен (границы 50/70/85 %
|
||||
от Hr.MaxBpm по ZoneCalculator);
|
||||
- кнопка «Взять с часов»: active/inactive по freshHr, подстановка
|
||||
значения;
|
||||
- логика «Больше не спрашивать» — на уровне VM-флага (системный
|
||||
диалог — ручная приёмка);
|
||||
- Compose-иерархия — без unit (ручной смок владельца, критерий 3).
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
# Требования к модулю: m12-watch-channel
|
||||
|
||||
> Модуль — **WatchChannel** телефона `Phone.WatchBody` (архитектура
|
||||
> разд. 1.2): приём live-пульса с часов Fenix 6X по штатной трансляции
|
||||
> BLE (Heart Rate Service), статус связи, заряд (если передаётся),
|
||||
> подача в экран «Тренировка» (m10), «Допуск» и запись среднего
|
||||
> пульса тренировки (`avg_hr`, цепочка m09). Самодостаточная копия
|
||||
> требований (d-dev-6). Источники: Ф-3.1–Ф-3.3 (статусная строка,
|
||||
> пульс, таймер), Ф-13.2 (пульс сидя «с часов, если связаны»),
|
||||
> Ф-2.4 (вибрация отдыха — errata, см. разд. 1.1), ОВ-3 (tech-req
|
||||
> 3 п. 3, прототип 7 п. 1), ui-ux разд. 2.
|
||||
|
||||
## 1. Назначение и границы
|
||||
|
||||
Live-пульс на телефоне во время тренировки: часы Fenix 6X транслируют
|
||||
пульс штатной функцией «Broadcast Heart Rate», телефон принимает и
|
||||
показывает; отсутствие связи ничего не ломает (🛡 Ф-2.4, Ф-3.2 —
|
||||
деградация молча, прочерки).
|
||||
|
||||
### 1.1. Транспорт: обоснование (errata ОВ-3)
|
||||
|
||||
ОВ-3 (tech-req 3 п. 3) предполагал companion-канал Connect IQ
|
||||
Communications. Исследование 2026-10-19 показало, что на якорных
|
||||
часах он нереализуем для живого пульса:
|
||||
|
||||
1. **Поля данных не имеют `Toybox.Communications` до API 5.0.0**,
|
||||
а Fenix 6X навсегда остался на CIQ API ≤ 4.1.7 — поле данных
|
||||
«Силовая» (Ф-2, m13) не может ни передавать, ни принимать.
|
||||
2. **CIQ-приложение** (Communications доступен) не может работать
|
||||
одновременно со штатной активностью «Силовая» — живой пульс
|
||||
во время тренировки из приложения не транслировать; companion
|
||||
SDK (`com.garmin.connectiq:ciq-companion-app-sdk`) для v1
|
||||
неприменим.
|
||||
3. **Штатная трансляция «Broadcast Heart Rate»** на fenix 6 серии
|
||||
с прошивки июня 2020 идёт одновременно по ANT+ и BLE
|
||||
(стандартный Heart Rate Service 0x180D; подтверждено на
|
||||
Pro-линии — 6/6S/6X Pro; у базовой «шестёрки» 2022 г. сообщали
|
||||
только ANT+). BLE-приём работает на любом Android без Garmin
|
||||
Connect Mobile и без ANT-железа; канал односторонний
|
||||
(часы → телефон).
|
||||
4. Следствие для **Ф-2.4** (вибрация конца отдыха «по таймеру,
|
||||
переданному с телефона»): передать секунды отдыха на часы
|
||||
в v1 нечем — таймер отдыха на часах делает m13 **автономно**
|
||||
(длительность — настройка поля данных в Garmin Connect Mobile,
|
||||
умолчание = `Rest.DefaultSec` телефона). Таймер телефона (Ф-3.3)
|
||||
работает независимо, как сейчас. Errata НД — разд. 5.
|
||||
|
||||
Критерий прототипа ОВ-3 (tech-req 7 п. 1 «пульс и таймер доходят
|
||||
при экране часов активным») исполняется в части «пульс доходит»
|
||||
(проверка на устройстве владельца); часть про таймер переносится
|
||||
в m13 как автономная вибрация (errata 7 п. 1).
|
||||
|
||||
**В границах модуля:**
|
||||
|
||||
- **транспорт-интерфейс** `WatchTransport` + первая реализация —
|
||||
**BLE-клиент** `HrBleClient` (требование владельца при утверждении
|
||||
гейта 1.3, 2026-10-19: инфраструктура должна поддерживать и другие
|
||||
умные часы): скан по UUID Heart Rate Service
|
||||
(0x180D), подключение, подписка на изменения характеристики
|
||||
Measurement (0x2A37, indications), разбор пакета (флаги: разрядность
|
||||
8/16 бит, поля Energy/RR пропускаются), автопереподключение при
|
||||
потере, чтение Battery Service (0x180F) при его наличии — иначе
|
||||
заряд не показывается (Ф-3.1 «если передаётся»); интерфейс —
|
||||
для unit-тестов на фейках и для будущих транспортов (CIQ 5+,
|
||||
ANT+) без правок фасада и UI; только стандартные профили GATT,
|
||||
платформо-специфичных UUID/имён/логики в коде нет — любые часы,
|
||||
транслирующие стандартный Heart Rate Service, работают без
|
||||
изменений кода; платформо-специчное — только тексты подсказок
|
||||
включения трансляции в «Настройках» (расширяемый справочник
|
||||
по производителям, v1 — Garmin);
|
||||
- **буфер пульса** `HrSampleStore` (чистый, в памяти): сэмплы
|
||||
(bpm, `t_offset_s` от старта тренировки), текущее значение,
|
||||
среднее за тренировку, серия «последний подход» (сэмплы от
|
||||
последнего записанного подхода до текущего момента — Ф-3.2
|
||||
«линия за последний подход»), устаревание (> 10 с без сэмплов —
|
||||
«нет данных», прочерк);
|
||||
- **фасад** `WatchChannel`: состояние канала — `StateFlow` со
|
||||
статусом (не выбраны / скан / связь есть / потеряна / Bluetooth
|
||||
выключен / нет разрешения 🛡 текстом), текущим пульсом, зарядом,
|
||||
серией последнего подхода и средним; жизненный цикл: канал активен,
|
||||
пока открыты экраны «Тренировка» или «Допуск» (v1 — без
|
||||
foreground-сервиса; пульс считается за время открытого экрана —
|
||||
ограничение решения 5 гейта 1.3);
|
||||
- **выбор устройства**: в «Настройках» блок «Часы» — скан, список
|
||||
найденных пульс-датчиков (имя + адрес), выбор сохраняется
|
||||
(`Watch.HrDeviceAddress` в DataStore); не задано — статус «часы
|
||||
не выбраны», канал не сканирует; сброс выбора;
|
||||
- **экран «Тренировка»** (интеграция m10, замена заглушек
|
||||
WorkoutScreen: статус-строка): связь часов — зелёный/красный/
|
||||
серый, дублируется текстом («часы: связь есть (заряд 80 %)» /
|
||||
«часы: нет связи» / «часы: не выбраны»); пульс — крупное текущее
|
||||
значение, окраска по зонам Танака (`ZoneCalculator` m10, границы
|
||||
из `Hr.MaxBpm`), под значением — линия последнего подхода
|
||||
(Canvas, серия буфера); при отсутствии/устаревании — прочерк,
|
||||
голос и ручной ввод работают (Ф-1.1, Ф-3.2);
|
||||
- **финиш тренировки**: `WorkoutCore.finishWorkout(avgHr = среднее
|
||||
буфера)` — готовая цепочка m09 (`workout.avg_hr` → sync);
|
||||
канал сам в БД не пишет;
|
||||
- **экран «Допуск»** (Ф-13.2): если канал активен и есть свежий
|
||||
сэмпл (≤ 30 с) — кнопка «взять с часов» подставляет пульс сидя
|
||||
в поле допуска; иначе — ручной ввод пикером (как в m10);
|
||||
- **разрешения Bluetooth**: Android 12+ — `BLUETOOTH_SCAN`
|
||||
(neverForLocation) + `BLUETOOTH_CONNECT` (runtime); Android
|
||||
8–11 — `ACCESS_FINE_LOCATION` (runtime) + `BLUETOOTH`/
|
||||
`BLUETOOTH_ADMIN` (манифест); отказ в разрешении — статус
|
||||
«нет разрешения», экран не блокируется (🛡);
|
||||
- unit-тесты (pure JVM): разбор 0x2A37 (фикстуры байтов: 8/16 бит,
|
||||
с полями Energy/RR), буфер (среднее, серия последнего подхода,
|
||||
устаревание), статусные переходы фасада на фейках клиента.
|
||||
|
||||
**Вне границ модуля:** m13 поле данных (Monkey C — отображение,
|
||||
зоны, автономный таймер отдыха с настройкой длительности;
|
||||
мультитаргет-сборка по моделям — там же), дополнительные
|
||||
реализации `WatchTransport` — ANT+-приём (плагин Garmin, нужен
|
||||
ANT-транспорт в телефоне) и CIQ companion (часы с CIQ 5+) —
|
||||
кандидаты v2 по мере появления устройств, foreground-фон пульса
|
||||
при погашенном экране (v2),
|
||||
запись live-сэмплов в LocalDb/sync (история пульса — из .fit,
|
||||
m07/m08), пульсограмма всей тренировки на телефоне (Ф-5 —
|
||||
карточка факт из .fit на сервере m08), wake-слова, смена устройства
|
||||
голосом.
|
||||
|
||||
## 2. Решения интерфейса (ui-ux разд. 2, tech-req 6)
|
||||
|
||||
- Статусная строка часов не конкурирует с пульсом: статус — мелкой
|
||||
строкой сверху, пульс — крупным значением (≥ 40 sp) в зоне пульса;
|
||||
- статус и доступность связи дублируются текстом (🛡 цвет — только
|
||||
дублирование, не единственный носитель);
|
||||
- линия последнего подхода — без сеток и подписей, минимум чернил;
|
||||
значение пульса — цифрой всегда;
|
||||
- «взять с часов» на допуске — вторичная кнопка рядом с полем
|
||||
«Пульс сидя», после нажатия значение видно в поле (правка
|
||||
пикером сохраняется);
|
||||
- блок «Часы» в «Настройках» — по образцу блока «Распознавание
|
||||
речи» (m11): статус, действие, инструкция текстом («включите
|
||||
Broadcast Heart Rate на часах»).
|
||||
|
||||
## 3. Инварианты (🛡 — нарушать запрещено)
|
||||
|
||||
1. **Офлайн-first**: приём пульса — локальный BLE, сеть и облако
|
||||
не используются; сервер не участвует (БЦ-3, Ф-1.1).
|
||||
2. **Деградация молча** (Ф-2.4, Ф-3.2): отсутствие часов, потери
|
||||
связи, отказ разрешения, выключенный Bluetooth — только прочерки
|
||||
и статус; тренировка, голос, ручной ввод и sync не блокируются.
|
||||
3. **Канал не пишет в БД**: единственная точка записи —
|
||||
`WorkoutCore.finishWorkout(avgHr)` (единственный писатель
|
||||
LocalDb — инвариант m09); live-сэмплы никуда не сохраняются.
|
||||
4. **Часы не зависят от телефона** (Ф-2.4 «не зависимость»):
|
||||
телефон ничего не «требует» от часов; трансляцию включает сам
|
||||
атлет на часах (инструкция в «Настройках»).
|
||||
5. **Тайны**: BLE-адрес устройства хранится только в DataStore
|
||||
телефона; в лог/журнал команд адреса не пишутся (З-6).
|
||||
6. **Платформо-независимость канала** (указание владельца
|
||||
2026-10-19): любые часы, транслирующие стандартный Heart Rate
|
||||
Service, работают без изменений кода; Garmin/fenix-специфика
|
||||
в коде запрещена; новые транспорты подключаются реализацией
|
||||
`WatchTransport` без правок фасада, UI и БД; то же требование
|
||||
наследуется m13 — мультитаргет-сборка поля данных по моделям
|
||||
(vision 1.1, Ф-2.8).
|
||||
|
||||
## 4. Критерии приёмки модуля
|
||||
|
||||
1. `assembleDebug` собирается; unit-тесты (все, включая m09–m11)
|
||||
зелёные.
|
||||
2. Приёмка на устройстве владельца (Fenix 6X, см. инструкцию
|
||||
в readme): включить «Broadcast Heart Rate» → «Настройки → Часы»
|
||||
→ скан → выбрать часы → экран «Тренировка»: статус «связь есть»,
|
||||
пульс обновляется ≈1 Гц при активном экране часов, линия
|
||||
последнего подхода рисуется; финиш → `avg_hr` в записи
|
||||
(История на телефоне, карточка в консоли m08 после sync);
|
||||
«Допуск» → «взять с часов» подставляет пульс.
|
||||
3. Отрицательные исходы: часы не выбраны / трансляция выключена /
|
||||
Bluetooth выключен / отказ разрешения — прочерки и статус
|
||||
текстом, приложение работает (ручной смок владельца).
|
||||
4. Прототип ОВ-3 (tech-req 7 п. 1) — в части «пульс доходит при
|
||||
экране часов активным»: подтверждается пунктом 2; фиксируется
|
||||
в журнале приёмки владельца; часть «таймер» — errata, m13.
|
||||
|
||||
## 5. Errata НД (по утверждению гейта 1.3, решение 2; ВНЕСЕНА 2026-10-19)
|
||||
|
||||
- requirements **Ф-2.4**: «вибрация — конец таймера отдыха,
|
||||
переданного с телефона» → «вибрация — конец автономного таймера
|
||||
отдыха поля; длительность — настройка поля в Garmin Connect
|
||||
(умолчание `Rest.DefaultSec`); передача с телефона — v2
|
||||
(кандидат при смене часов на CIQ 5+)»;
|
||||
- requirements **Ф-3.2**: источник пульса — «штатная трансляция
|
||||
пульса часов (BLE Heart Rate Service; ОВ-3 закрыт: companion-
|
||||
канал Connect IQ на fenix 6X недоступен для полей данных)»;
|
||||
- tech-requirements **3 п. 3 (ОВ-3)**: описание канала фактически =
|
||||
односторонняя штатная трансляция «Broadcast Heart Rate»
|
||||
(BLE/ANT+), Garmin Connect Mobile для пульса не нужен;
|
||||
- tech-requirements **7 п. 1**: критерий прототипа — «пульс
|
||||
доходит на телефон при активном экране часов» (таймер —
|
||||
автономный, проверяется в m13);
|
||||
- architecture **1.1/1.2/3** (телефон↔часы): канал = BLE-приём
|
||||
штатной трансляции, односторонний.
|
||||
|
||||
Волна правок НД вносится после «утверждаю» по гейту 1.3 одним
|
||||
пакетом (без подъёма версий документов — errata в телах разделов,
|
||||
версии поднимутся на ближайшем ревью).
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
# m12-watch-channel — план работ (d-dev)
|
||||
|
||||
> Модуль: WatchChannel — приём live-пульса с Fenix 6X по штатной
|
||||
> трансляции BLE (Heart Rate Service), статус связи/заряд, подача
|
||||
> в «Тренировку»/«Допуск», `avg_hr` при финише. Транспорт-обоснование
|
||||
> и errata ОВ-3/Ф-2.4 — [module_reqs.md](module_reqs.md) разд. 1.1/5.
|
||||
> Алгоритм: `std1\_rules\d\d-dev.md`.
|
||||
> Зависимости: m09 (WorkoutCore/`finishWorkout(avgHr)`, DataStore —
|
||||
> DONE), m10 (экраны «Тренировка»/«Допуск»/«Настройки»,
|
||||
> ZoneCalculator — DONE), m11 (DONE; независимо). Часы: Fenix 6X
|
||||
> (якорь, владелец, 2026-10-19).
|
||||
|
||||
| Шаг | Что делаем | Статус |
|
||||
|---|---|---|
|
||||
| 0 | Модульные документы: module_reqs.md / readme.md / SKILL.md | ✅ 2026-10-19 |
|
||||
| 1 | Список единиц (BLE-клиент/буфер/фасад/UI×3/разрешения/тесты) | ✅ 2026-10-19 |
|
||||
| 1.3 | **Пользователь!** утверждение списка (+ решения по транспорту и errata Ф-2.4) | ✅ 2026-10-19 (+ указание: инфраструктура мультичасовых сборок) |
|
||||
| 2 | Спецификации: api_specs (BLE-клиент/буфер/фасад, UI×3, payload — без изменений контракта) | ✅ 2026-10-19 (01-transport+BLE, 02-store, 03-facade, 04-UI) |
|
||||
| 2.4 | **Пользователь!** утверждение спецификаций | ✅ 2026-10-19 |
|
||||
| 3 | Реализация + тесты + сборка | ✅ 2026-10-19 |
|
||||
| 3.4 | **Пользователь!** утверждение реализации (закрытие модуля) | ✅ 2026-10-19 |
|
||||
| 4 | Завершение: план, примечания, волна errata НД | ✅ 2026-10-19 |
|
||||
|
||||
Статусы: ⬜ не начат · 🔄 в работе · ✅ готов.
|
||||
|
||||
## Список единиц (шаг 1)
|
||||
|
||||
| # | Единица | Что делает |
|
||||
|---|---|---|
|
||||
| 1 | `WatchTransport` + `HrBleClient` | транспорт-интерфейс (события/статусы, мультичасовая инфраструктура — решение 6) + BLE-реализация: скан по UUID 0x180D, подключение, indications 0x2A37, разбор пакета (8/16 бит, Energy/RR пропуск), автопереподключение, Battery Service (0x180F) при наличии |
|
||||
| 2 | `HrSampleStore` | чистый буфер в памяти: сэмплы (bpm, `t_offset_s`), текущее/среднее/серия «последний подход», устаревание >10 с |
|
||||
| 3 | `WatchChannel` (фасад) | `StateFlow` статуса (не выбраны/скан/связь/потеря/BT-выкл/нет разрешения 🛡 текстом) + пульс + заряд + серия; активен при открытых экранах «Тренировка»/«Допуск» |
|
||||
| 4 | UI «Тренировка» | статус-строка часов (зелёный/красный/серый + текст, заряд), крупный пульс с окраской по зонам Танака, линия последнего подхода (Canvas), прочерки при отсутствии; `finishWorkout(avgHr=среднее)` |
|
||||
| 5 | UI «Допуск» | кнопка «взять с часов» при свежем сэмпле ≤30 с → поле «Пульс сидя» (Ф-13.2) |
|
||||
| 6 | UI «Настройки» | блок «Часы»: скан/список/выбор (`Watch.HrDeviceAddress`), статус, сброс, инструкция «включите Broadcast Heart Rate» |
|
||||
| 7 | Разрешения | `BLUETOOTH_SCAN`(neverForLocation)+`BLUETOOTH_CONNECT` (12+, runtime), `ACCESS_FINE_LOCATION`+`BLUETOOTH/BLUETOOTH_ADMIN` (8–11); отказ — статус, не блок |
|
||||
| 8 | `WorkoutCore.finishWorkout(avgHr)` | подстановка среднего из канала при финише (цепочка m09 готова: `workout.avg_hr` → sync `avg_hr`) |
|
||||
| 9 | unit-тесты | парсер 0x2A37 (байтовые фикстуры), буфер (среднее/серия/устаревание), фасад на фейках (переходы статусов) |
|
||||
|
||||
## Решения на гейт 1.3 (для владельца)
|
||||
|
||||
1. **Транспорт live-пульса** — рекомендуем (а): штатная трансляция
|
||||
«Broadcast Heart Rate» Fenix 6X, приём по **BLE** (стандартный
|
||||
Heart Rate Service; работает на любом Android, Garmin Connect
|
||||
Mobile не нужен; трансляция ANT++BLE на Pro-линии fenix 6 —
|
||||
прошивка июня 2020). Альтернативы: (б) ANT+-плагин Garmin —
|
||||
нужен ANT-транспорт в телефоне (Samsung до S20 или USB-стик),
|
||||
в v1 не реализуем (кандидат v2); (в) CIQ companion SDK — живой
|
||||
пульс не даёт: поле данных на fenix 6X без Communications
|
||||
(CIQ ≤ 4.1.7 < 5.0), CIQ-приложение не работает во время
|
||||
штатной активности.
|
||||
2. **Errata Ф-2.4 / ОВ-3** — таймер отдыха на часах автономный
|
||||
(длительность — настройка поля в Garmin Connect, умолчание
|
||||
`Rest.DefaultSec`); с телефона в v1 не передаётся (канал
|
||||
односторонний). Пакет правок НД — module_reqs разд. 5,
|
||||
вносится после утверждения.
|
||||
3. **Заряд часов** — из Battery Service, если трансляция его
|
||||
exposing; иначе прочерк (Ф-3.1 «если передаётся» допускает).
|
||||
4. **Хранение live-пульса** — только в памяти: буфер линии
|
||||
последнего подхода + среднее в `workout.avg_hr` при финише;
|
||||
сэмплы в LocalDb/sync не пишутся (история пульса — из .fit
|
||||
m07/m08), схема БД и sync-контракт не меняются.
|
||||
5. **Жизненный цикл канала** — активен при открытых экранах
|
||||
«Тренировка»/«Допуск» (foreground-сервис — кандидат v2);
|
||||
среднее считается за время работы канала (ограничение
|
||||
фиксируется в readme приёмки).
|
||||
6. **Мультичасовая инфраструктура** (указание владельца при
|
||||
утверждении гейта 1.3, 2026-10-19) — транспорт скрывается
|
||||
за интерфейсом `WatchTransport` (BLE-реализация — первая;
|
||||
CIQ 5+/ANT+ — реализациями того же интерфейса, без правок
|
||||
фасада/UI/БД); в коде только стандартные профили GATT,
|
||||
без Garmin/fenix-специфики — любые часы со стандартной
|
||||
BLE-трансляцией пульса работают без изменений; платформо-
|
||||
специфичные тексты подсказок — расширяемый справочник
|
||||
(v1 — Garmin). Требование наследуется m13: мультитаргет-
|
||||
сборка по моделям/разрешениям (vision 1.1, Ф-2.8).
|
||||
|
||||
## Примечания по ходу работ
|
||||
|
||||
0. **Волна errata НД внесена (2026-10-19, после «утверждаю» гейта
|
||||
1.3):** requirements Ф-2.4/Ф-3.2 + реестр открытых вопросов № 3,
|
||||
tech-req 3 п. 3 (ОВ-3) + 7 п. 1, architecture 1.1/1.2/3/1.4 —
|
||||
транспорт = штатная BLE-трансляция «Broadcast Heart Rate»,
|
||||
канал односторонний, таймер отдыха на часах автономный,
|
||||
`WatchTransport`-интерфейс (мультичасовая инфраструктура).
|
||||
Версии документов не поднимались (ближайшее ревью).
|
||||
|
||||
1. **Исследование канала (2026-10-19, веб)**: fenix 6X — CIQ API
|
||||
max 4.1.7 (Communications в полях данных — с API 5.0.0, только
|
||||
foreground); BLE HR-броадкаст fenix 6 серии — firmware 2020
|
||||
(DCRainmaker, проверено на 6 Pro — Pro-линия 6X); Android
|
||||
companion SDK жив (`com.garmin.connectiq:ciq-companion-app-sdk`,
|
||||
Maven Central) — для v1 неприменим (см. решение 1).
|
||||
2. Инструкция включения трансляции (для приёмки, по руководству
|
||||
Garmin): на часах — виджет пульса → удержание MENU →
|
||||
«Heart Rate Options» → «Broadcast During Activity» = Вкл
|
||||
(или разовый «Broadcast Heart Rate» перед тренировкой).
|
||||
3. BLE-адрес часов при «Broadcast Heart Rate» виден как обычный
|
||||
пульс-датчик; в зале возможны чужие HRM — выбор устройства
|
||||
в «Настройках» обязателен (не авто-first-found).
|
||||
4. **Состав реализации** (`app/src/main/java/com/watchbody/phone/watch/`,
|
||||
7 файлов): `WatchTransport.kt` (интерфейс + TransportEvent/Status
|
||||
+ ReconnectPolicy 1/5/30 c + BleProfiles — только стандартные
|
||||
UUID), `HrPacketParser.kt` (0x2A37, 8/16 бит, Energy/RR пропуск,
|
||||
диапазон 30–250), `HrSampleStore.kt`, `HrBleClient.kt` (скан
|
||||
по 0x180D → connectGatt → indications 0x2A37; оба поколения
|
||||
GATT-колбэков — 33+ и <33; батарея 0x180F опционально; приёмник
|
||||
ACTION_STATE_CHANGED; автопереподключение), `WatchChannel.kt`
|
||||
(фасад: StateFlow статусов, bind/unbind/**dispose**, avgHr/
|
||||
freshHr/series/clearSamples, тикер устаревания 2 с),
|
||||
`WatchFormat.kt` (тексты/зоны/подписи — чистые), `WatchPermissions.kt`
|
||||
(+ AndroidChannelEnvironment), `DeviceScanner.kt` (скан 15 с
|
||||
для «Настроек»). Интеграция: `AppSettings.watchHrDeviceAddress`
|
||||
(DataStore), контейнер (watchChannel + deviceScanner),
|
||||
WorkoutScreen (статус-строка m12, строка NO_PERMISSION+«Разрешить»,
|
||||
HrBlock 44 sp с зоной и Canvas-линией, finish→avgHr+clear, bind/
|
||||
unbind/ребинд при возврате разрешения), AdmissionScreen («Взять
|
||||
с часов» + подпись причины), SettingsScreen (блок «Часы»: скан/
|
||||
список/тап-выбор/«Забыть»/инструкция), манифест — 5 разрешений
|
||||
(SCAN neverForLocation, CONNECT, BLUETOOTH/ADMIN ≤30,
|
||||
ACCESS_FINE_LOCATION).
|
||||
5. **Сборка/тесты**: BUILD SUCCESSFUL; unit **89/89** (m12 +26:
|
||||
парсер 7, буфер 6, фасад 9 — включая ReconnectPolicy, формат 4);
|
||||
APK 57,3 МБ (рост на ~0 — доминируют JNI Vosk из m11).
|
||||
6. **Уточнения по ходу** (спеки не меняют наблюдаемое поведение):
|
||||
NO_PERMISSION вычисляет фасад, в TransportStatus не входит
|
||||
(спека 01 ставила его в транспорт); добавлен `WatchChannel.dispose()`
|
||||
— полная остановка для тестов (в проде канал живёт с контейнером).
|
||||
7. **Не проверено в среде** (нет телефона/часов в контуре агента):
|
||||
живой BLE-приём с Fenix 6X, батарея через Battery Service,
|
||||
реальный скан, runtime-разрешения — критерии 2–4 приёмки на
|
||||
стороне владельца; логика покрыта фейками и байтовыми фикстурами.
|
||||
|
|
@ -4,6 +4,18 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- голос m11: runtime-запрос из кнопки микрофона (spec 05 §6) -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<!-- часы m12: BLE-трансляция пульса (spec 04 §5) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission
|
||||
android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<application
|
||||
android:name="com.watchbody.phone.WatchBodyApp"
|
||||
android:label="Феникс 6X"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,21 @@ class AppContainer(context: android.content.Context) {
|
|||
}
|
||||
val ttsFeedback: AndroidTtsFeedback by lazy { AndroidTtsFeedback(appContext) }
|
||||
|
||||
/** m12: канал часов — транспорт BLE за интерфейсом WatchTransport (мультичасовая инфраструктура). */
|
||||
val watchChannel: com.watchbody.phone.watch.WatchChannel by lazy {
|
||||
com.watchbody.phone.watch.WatchChannel(
|
||||
transport = com.watchbody.phone.watch.HrBleClient(appContext),
|
||||
store = com.watchbody.phone.watch.HrSampleStore(),
|
||||
environment = com.watchbody.phone.watch.AndroidChannelEnvironment(appContext),
|
||||
scope = kotlinx.coroutines.CoroutineScope(
|
||||
kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate,
|
||||
),
|
||||
)
|
||||
}
|
||||
val deviceScanner: com.watchbody.phone.watch.DeviceScanner by lazy {
|
||||
com.watchbody.phone.watch.DeviceScanner(appContext)
|
||||
}
|
||||
|
||||
/** Клиент по актуальным настройкам — создаётся на каждое обращение. */
|
||||
suspend fun api(): com.watchbody.phone.sync.WatchBodyApi {
|
||||
val s = settings.get()
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ data class AppSettings(
|
|||
val safety: SafetySettings = SafetySettings(),
|
||||
val hrStrengthMax: Int = 120, // предупреждение во время силовой (Ф-13.7, применяется в m12)
|
||||
val voiceModelUrl: String = "https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip", // m11
|
||||
val watchHrDeviceAddress: String = "", // m12: BLE-адрес часов; "" — не выбраны (spec 03 §5)
|
||||
)
|
||||
|
||||
class SettingsRepository(private val context: Context) {
|
||||
|
|
@ -40,6 +41,7 @@ class SettingsRepository(private val context: Context) {
|
|||
val restingHrSitting = intPreferencesKey("safety.resting_hr_sitting")
|
||||
val hrStrengthMax = intPreferencesKey("safety.hr_strength_max")
|
||||
val voiceModelUrl = stringPreferencesKey("voice.model_url")
|
||||
val watchHrDeviceAddress = stringPreferencesKey("watch.hr_device_address")
|
||||
}
|
||||
|
||||
suspend fun get(): AppSettings = context.dataStore.data.first().toSettings()
|
||||
|
|
@ -58,6 +60,7 @@ class SettingsRepository(private val context: Context) {
|
|||
prefs[Keys.restingHrSitting] = settings.safety.restingHrSitting
|
||||
prefs[Keys.hrStrengthMax] = settings.hrStrengthMax
|
||||
prefs[Keys.voiceModelUrl] = settings.voiceModelUrl
|
||||
prefs[Keys.watchHrDeviceAddress] = settings.watchHrDeviceAddress
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +81,7 @@ class SettingsRepository(private val context: Context) {
|
|||
),
|
||||
hrStrengthMax = this[Keys.hrStrengthMax] ?: defaults.hrStrengthMax,
|
||||
voiceModelUrl = this[Keys.voiceModelUrl] ?: defaults.voiceModelUrl,
|
||||
watchHrDeviceAddress = this[Keys.watchHrDeviceAddress] ?: "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import androidx.compose.material3.OutlinedButton
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -44,6 +47,20 @@ class AdmissionViewModel(private val container: AppContainer) : ViewModel() {
|
|||
var activeVersionId: String? = null
|
||||
private set
|
||||
|
||||
// часы m12 (spec 04 §2): пульс покоя до активности — свежий сэмпл ≤30 с
|
||||
val watchState: kotlinx.coroutines.flow.StateFlow<com.watchbody.phone.watch.WatchChannelState> =
|
||||
container.watchChannel.state
|
||||
|
||||
fun bindWatch() {
|
||||
viewModelScope.launch {
|
||||
container.watchChannel.bind(null, container.settings.get().watchHrDeviceAddress)
|
||||
}
|
||||
}
|
||||
|
||||
fun unbindWatch() = container.watchChannel.unbind()
|
||||
|
||||
fun freshHr(): Int? = container.watchChannel.freshHr()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
safety = container.settings.get().safety
|
||||
|
|
@ -89,6 +106,11 @@ class AdmissionViewModel(private val container: AppContainer) : ViewModel() {
|
|||
fun AdmissionScreen(container: AppContainer, onStarted: () -> Unit) {
|
||||
val vm = containerVM(container) { AdmissionViewModel(it) }
|
||||
|
||||
LaunchedEffect(Unit) { vm.bindWatch() }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { vm.unbindWatch() }
|
||||
}
|
||||
|
||||
var bpSys by remember { mutableStateOf<Int?>(null) }
|
||||
var bpDias by remember { mutableStateOf<Int?>(null) }
|
||||
var hrSitting by remember { mutableStateOf<Int?>(null) }
|
||||
|
|
@ -115,6 +137,17 @@ fun AdmissionScreen(container: AppContainer, onStarted: () -> Unit) {
|
|||
IntPicker("АД систола", bpSys, { bpSys = it }, min = 70, max = 250, step = 5, start = 120, suffix = " мм рт.ст.")
|
||||
IntPicker("АД диастола", bpDias, { bpDias = it }, min = 40, max = 150, step = 5, start = 80, suffix = " мм рт.ст.")
|
||||
IntPicker("Пульс сидя", hrSitting, { hrSitting = it }, min = 30, max = 180, start = 70, suffix = " уд/мин")
|
||||
val ws by vm.watchState.collectAsState()
|
||||
val fresh = vm.freshHr()
|
||||
OutlinedButton(
|
||||
onClick = { fresh?.let { hrSitting = it } },
|
||||
enabled = fresh != null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Взять с часов") }
|
||||
Text(
|
||||
com.watchbody.phone.watch.WatchFormat.admissionHint(ws.status == com.watchbody.phone.watch.WatchStatus.CONNECTED, fresh),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.watchbody.phone.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -98,6 +99,30 @@ class SettingsViewModel(private val container: AppContainer) : ViewModel() {
|
|||
SyncWorker.scheduleNow(container.appContext)
|
||||
}
|
||||
|
||||
// m12: часы (spec 04 §3) — скан/выбор/сброс устройства
|
||||
var watchScanState by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var watchScanResults by mutableStateOf<List<Pair<String, String>>>(emptyList())
|
||||
private set
|
||||
|
||||
fun scanWatches() {
|
||||
watchScanState = "Поиск 15 с…"
|
||||
container.deviceScanner.scan { results ->
|
||||
watchScanResults = results
|
||||
watchScanState = when {
|
||||
results.isEmpty() -> "Ничего не найдено — включите трансляцию пульса на часах"
|
||||
else -> "Найдено: ${results.size}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveWatchAddress(address: String) {
|
||||
viewModelScope.launch {
|
||||
val s = container.settings.get()
|
||||
container.settings.save(s.copy(watchHrDeviceAddress = address))
|
||||
}
|
||||
}
|
||||
|
||||
val queueCount = container.db.syncDao().countFlow()
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +145,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
|
|||
var restingHrSitting by remember { mutableStateOf(90) }
|
||||
var hrStrengthMax by remember { mutableStateOf(120) }
|
||||
var voiceModelUrl by remember { mutableStateOf("https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip") }
|
||||
var watchAddress by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(vm.loaded) { // одна загрузка в форму
|
||||
vm.loaded?.let { s ->
|
||||
|
|
@ -131,6 +157,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
|
|||
restingHrSitting = s.safety.restingHrSitting
|
||||
hrStrengthMax = s.hrStrengthMax
|
||||
voiceModelUrl = s.voiceModelUrl
|
||||
watchAddress = s.watchHrDeviceAddress
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,6 +169,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
|
|||
),
|
||||
hrStrengthMax = hrStrengthMax,
|
||||
voiceModelUrl = voiceModelUrl,
|
||||
watchHrDeviceAddress = watchAddress,
|
||||
)
|
||||
val validation = SettingsValidator.validate(draft)
|
||||
|
||||
|
|
@ -232,6 +260,53 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
|
|||
}
|
||||
vm.ttsState?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
|
||||
|
||||
Text("Часы (пульс по Bluetooth)", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
if (watchAddress.isBlank()) "Часы не выбраны — пульс на экране тренировки покажется прочерком"
|
||||
else "Выбраны: $watchAddress",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val btPermission = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
if (com.watchbody.phone.watch.WatchPermissions.granted(context)) {
|
||||
vm.scanWatches()
|
||||
} else {
|
||||
btPermission.launch(com.watchbody.phone.watch.WatchPermissions.required())
|
||||
}
|
||||
},
|
||||
) { Text("Найти часы") }
|
||||
if (watchAddress.isNotBlank()) {
|
||||
OutlinedButton(onClick = { watchAddress = ""; vm.saveWatchAddress("") }) {
|
||||
Text("Забыть")
|
||||
}
|
||||
}
|
||||
}
|
||||
vm.watchScanState?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
|
||||
vm.watchScanResults.forEach { (name, addr) ->
|
||||
Text(
|
||||
"• $name ($addr)" + if (addr == watchAddress) " — выбрано" else "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (addr == watchAddress) MaterialTheme.colorScheme.primary else androidx.compose.ui.graphics.Color.Unspecified,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
watchAddress = addr
|
||||
vm.saveWatchAddress(addr)
|
||||
}
|
||||
.padding(vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
"Включите на часах трансляцию пульса по Bluetooth. " +
|
||||
"Garmin: виджет пульса → удержание MENU → Heart Rate Options → Broadcast During Activity = Вкл.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
if (!standalone) {
|
||||
Text(
|
||||
"Очередь отправки: $queue записей",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.watchbody.phone.ui
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
|
|
@ -7,6 +8,7 @@ import androidx.compose.foundation.layout.Column
|
|||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
|
|
@ -39,6 +41,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
|
|
@ -49,14 +52,23 @@ import com.watchbody.phone.core.ProgramView
|
|||
import com.watchbody.phone.core.RestPhase
|
||||
import com.watchbody.phone.core.RestTimer
|
||||
import com.watchbody.phone.core.SetPlan
|
||||
import com.watchbody.phone.core.ZoneCalculator
|
||||
import com.watchbody.phone.db.Exercise
|
||||
import com.watchbody.phone.db.Workout
|
||||
import com.watchbody.phone.db.WorkoutSet
|
||||
import com.watchbody.phone.sync.SyncWorker
|
||||
import com.watchbody.phone.voice.VoiceEffect
|
||||
import com.watchbody.phone.voice.humanKg
|
||||
import com.watchbody.phone.watch.HrZoneColor
|
||||
import com.watchbody.phone.watch.StatusColor
|
||||
import com.watchbody.phone.watch.WatchChannelState
|
||||
import com.watchbody.phone.watch.WatchFormat
|
||||
import com.watchbody.phone.watch.WatchPermissions
|
||||
import com.watchbody.phone.watch.WatchStatus
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
|
|
@ -105,6 +117,66 @@ class WorkoutViewModel(private val container: AppContainer) : ViewModel() {
|
|||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), WorkoutUiState())
|
||||
|
||||
// ---------- часы m12 (spec 03 §4, 04 §1) ----------
|
||||
|
||||
val watchState: StateFlow<WatchChannelState> = container.watchChannel.state
|
||||
|
||||
/** Линия последнего подхода (Ф-3.2) — от последнего logSet до сейчас. */
|
||||
var hrSeries by mutableStateOf<List<Pair<Long, Int>>>(emptyList())
|
||||
private set
|
||||
var hrZones by mutableStateOf<ZoneCalculator.Zones?>(null)
|
||||
private set
|
||||
|
||||
private var lastSetAtMs: Long? = null
|
||||
private var seriesJob: Job? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch { watchState.collect { refreshSeries() } }
|
||||
viewModelScope.launch {
|
||||
state.collect { st ->
|
||||
val from = st.workout?.let { w -> st.sets.lastOrNull()?.doneAt ?: w.startedAt }
|
||||
if (from != lastSetAtMs) {
|
||||
lastSetAtMs = from
|
||||
refreshSeries()
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val s = container.settings.get()
|
||||
val p = container.db.healthDao().profile()
|
||||
ZoneCalculator.maxHr(p?.birthYear, p?.hrMax ?: s.hrMaxBpm, ProfileViewModel.currentYear())
|
||||
?.let { hrZones = ZoneCalculator.zones(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Открытие/возврат на экран или смена адреса — канал активен при открытом экране. */
|
||||
fun rebindWatch() {
|
||||
viewModelScope.launch {
|
||||
val st = state.value
|
||||
lastSetAtMs = st.workout?.let { w -> st.sets.lastOrNull()?.doneAt ?: w.startedAt }
|
||||
container.watchChannel.bind(st.workout?.startedAt, container.settings.get().watchHrDeviceAddress)
|
||||
refreshSeries()
|
||||
seriesJob?.cancel()
|
||||
seriesJob = viewModelScope.launch {
|
||||
while (true) {
|
||||
delay(2_000)
|
||||
refreshSeries()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun unbindWatch() {
|
||||
seriesJob?.cancel()
|
||||
seriesJob = null
|
||||
container.watchChannel.unbind()
|
||||
}
|
||||
|
||||
private fun refreshSeries() {
|
||||
val from = lastSetAtMs ?: return
|
||||
hrSeries = container.watchChannel.series(from)
|
||||
}
|
||||
|
||||
// ---------- голос m11 (spec 05 §1–3) ----------
|
||||
|
||||
var listening by mutableStateOf(false)
|
||||
|
|
@ -255,7 +327,8 @@ class WorkoutViewModel(private val container: AppContainer) : ViewModel() {
|
|||
|
||||
fun finish(workoutId: String, onDone: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
container.core.finishWorkout(workoutId, avgHr = null) // пульс — m12
|
||||
container.core.finishWorkout(workoutId, avgHr = container.watchChannel.avgHr()) // m12: среднее канала
|
||||
container.watchChannel.clearSamples()
|
||||
SyncWorker.schedule(container.appContext, container.settings.get().wifiOnly)
|
||||
onDone()
|
||||
}
|
||||
|
|
@ -325,6 +398,16 @@ fun WorkoutScreen(container: AppContainer) {
|
|||
onDispose { vm.stopVoice() } // выход с экрана останавливает прослушивание (Ф-1.1)
|
||||
}
|
||||
|
||||
// часы m12 (spec 04 §1/§4): канал активен при открытом экране
|
||||
val ws by vm.watchState.collectAsState()
|
||||
val btPermission = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { vm.rebindWatch() }
|
||||
LaunchedEffect(state.workout?.id) { vm.rebindWatch() }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { vm.unbindWatch() }
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
|
|
@ -377,19 +460,40 @@ fun WorkoutScreen(container: AppContainer) {
|
|||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
// статус-строка (Ф-3.1/3.2): часы/пульс — прочерки до m12
|
||||
// статус-строка (Ф-3.1): часы/пульс — m12; цвет дублируется текстом 🛡
|
||||
Text(
|
||||
"часы: нет связи пульс: — сеть: ${
|
||||
"часы: ${WatchFormat.watchStatusText(ws)} пульс: ${ws.hr ?: "—"} сеть: ${
|
||||
if (state.offline) "офлайн" else "онлайн"
|
||||
}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when (WatchFormat.statusColor(ws)) {
|
||||
StatusColor.GREEN -> Color(0xFF81C784)
|
||||
StatusColor.RED -> Color(0xFFE57373)
|
||||
StatusColor.GRAY -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
if (ws.status == WatchStatus.NO_PERMISSION) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
) {
|
||||
Text("Нет разрешения Bluetooth", style = MaterialTheme.typography.bodySmall)
|
||||
TextButton(onClick = { btPermission.launch(WatchPermissions.required()) }) {
|
||||
Text("Разрешить")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
"вид: ${kindLabel(w.sessionKind)}" + (w.programVersionId?.let { "" } ?: " (вне программы)"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
|
||||
if (ws.hr != null) {
|
||||
item { HrBlock(ws.hr!!, vm.hrZones, vm.hrSeries) }
|
||||
}
|
||||
|
||||
item {
|
||||
// голос: индикатор + последняя команда — текст, не только цвет 🛡
|
||||
Text(
|
||||
|
|
@ -509,6 +613,45 @@ fun WorkoutScreen(container: AppContainer) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Пульс (Ф-3.2): крупное значение ≥40 sp, зона словами (🛡 как на «Профиле»), линия последнего подхода. */
|
||||
@Composable
|
||||
private fun HrBlock(bpm: Int, zones: ZoneCalculator.Zones?, series: List<Pair<Long, Int>>) {
|
||||
val zone = zones?.let { WatchFormat.hrZone(bpm, it) }
|
||||
val color = when (zone?.color) {
|
||||
null -> MaterialTheme.colorScheme.onSurface
|
||||
HrZoneColor.BLUE -> Color(0xFF64B5F6)
|
||||
HrZoneColor.GREEN -> Color(0xFF81C784)
|
||||
HrZoneColor.YELLOW -> Color(0xFFFFD54F)
|
||||
HrZoneColor.RED -> Color(0xFFE57373)
|
||||
}
|
||||
Row(verticalAlignment = androidx.compose.ui.Alignment.Bottom) {
|
||||
Text(
|
||||
"$bpm",
|
||||
fontSize = 44.sp, // ≥40 sp, spec 04 §1
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
|
||||
color = color,
|
||||
)
|
||||
Column(Modifier.padding(start = 8.dp, bottom = 6.dp)) {
|
||||
Text("уд/мин", style = MaterialTheme.typography.bodySmall)
|
||||
zone?.let { Text(it.word, style = MaterialTheme.typography.bodyMedium) }
|
||||
}
|
||||
}
|
||||
Canvas(Modifier.fillMaxWidth().height(36.dp)) {
|
||||
if (series.size < 2) return@Canvas
|
||||
val minB = series.minOf { it.second } - 5
|
||||
val maxB = series.maxOf { it.second } + 5
|
||||
val t0 = series.first().first
|
||||
val dt = (series.last().first - t0).coerceAtLeast(1)
|
||||
val path = androidx.compose.ui.graphics.Path()
|
||||
series.forEachIndexed { i, (t, v) ->
|
||||
val x = size.width * ((t - t0).toFloat() / dt)
|
||||
val y = size.height * (1f - (v - minB).toFloat() / (maxB - minB))
|
||||
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||
}
|
||||
drawPath(path, color, style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx()))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RestCard(restLeft: Int, onStart: () -> Unit) {
|
||||
val phase = if (restLeft >= 0) RestTimer.phase(restLeft) else null
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package com.watchbody.phone.watch
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.ParcelUuid
|
||||
|
||||
/**
|
||||
* Скан пульс-датчиков для блока «Часы» в «Настройках» (m12 spec 04 §3):
|
||||
* 15 с по UUID 0x180D, список «имя → адрес». Выбор устройства вручную —
|
||||
* авто-подключение к первому найденному запрещено (чужие HRM в зале).
|
||||
*/
|
||||
@Suppress("MissingPermission") // вызывается только при выданных разрешениях (UI)
|
||||
class DeviceScanner(private val context: Context) {
|
||||
|
||||
/** [onDone] получает список (имя, адрес) по завершении окна скана. */
|
||||
fun scan(durationMs: Long = 15_000, onDone: (List<Pair<String, String>>) -> Unit) {
|
||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
||||
if (adapter == null || !adapter.isEnabled) {
|
||||
onDone(emptyList())
|
||||
return
|
||||
}
|
||||
val found = LinkedHashMap<String, String>() // адрес → имя
|
||||
val handler = Handler(Looper.getMainLooper())
|
||||
val callback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val name = result.device.name ?: result.scanRecord?.deviceName ?: "без имени"
|
||||
found[result.device.address] = name
|
||||
}
|
||||
}
|
||||
try {
|
||||
adapter.bluetoothLeScanner.startScan(
|
||||
listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(BleProfiles.HEART_RATE_SERVICE)).build()),
|
||||
ScanSettings.Builder().build(),
|
||||
callback,
|
||||
)
|
||||
} catch (_: SecurityException) {
|
||||
onDone(emptyList())
|
||||
return
|
||||
}
|
||||
handler.postDelayed({
|
||||
runCatching { adapter.bluetoothLeScanner.stopScan(callback) }
|
||||
onDone(found.map { (address, name) -> name to address })
|
||||
}, durationMs)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
package com.watchbody.phone.watch
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.ParcelUuid
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanFilter
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
/**
|
||||
* BLE-реализация транспорта: стандартный Heart Rate Service 0x180D
|
||||
* (штатная трансляция «Broadcast Heart Rate», spec 01 §2). Никаких
|
||||
* платформо-специфичных UUID/имён (инвариант 6) — любые часы со
|
||||
* стандартной трансляцией. Разрешения проверяет фасад (WatchPermissions).
|
||||
*/
|
||||
@Suppress("MissingPermission") // вызывается только при выданных разрешениях (фасад/UI)
|
||||
class HrBleClient(private val context: Context) : WatchTransport {
|
||||
|
||||
private val _events = MutableSharedFlow<TransportEvent>(
|
||||
extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
)
|
||||
override val events: SharedFlow<TransportEvent> = _events
|
||||
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val adapter: BluetoothAdapter?
|
||||
get() = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
||||
|
||||
private var started = false
|
||||
private var targetAddress: String? = null
|
||||
private var gatt: BluetoothGatt? = null
|
||||
private var scanning = false
|
||||
private var reconnectAttempt = 0
|
||||
private var reconnectRunnable: Runnable? = null
|
||||
private var receiverRegistered = false
|
||||
|
||||
private val btReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context?, intent: Intent?) {
|
||||
if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return
|
||||
when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) {
|
||||
BluetoothAdapter.STATE_OFF -> {
|
||||
closeGatt()
|
||||
cancelReconnect()
|
||||
if (started) emit(TransportEvent.Status(TransportStatus.BT_OFF))
|
||||
}
|
||||
BluetoothAdapter.STATE_ON -> if (started) startScan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val scanCallback = object : ScanCallback() {
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
if (result.device.address == targetAddress) {
|
||||
stopScan()
|
||||
connect(result.device)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun start(deviceAddress: String) {
|
||||
handler.post {
|
||||
if (started && targetAddress == deviceAddress) return@post
|
||||
started = true
|
||||
targetAddress = deviceAddress
|
||||
reconnectAttempt = 0
|
||||
registerReceiverOnce()
|
||||
val ad = adapter
|
||||
if (ad == null || !ad.isEnabled) {
|
||||
emit(TransportEvent.Status(TransportStatus.BT_OFF))
|
||||
return@post
|
||||
}
|
||||
startScan()
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
handler.post {
|
||||
started = false
|
||||
targetAddress = null
|
||||
cancelReconnect()
|
||||
stopScan()
|
||||
closeGatt()
|
||||
if (receiverRegistered) {
|
||||
runCatching { context.unregisterReceiver(btReceiver) }
|
||||
receiverRegistered = false
|
||||
}
|
||||
emit(TransportEvent.Status(TransportStatus.IDLE))
|
||||
}
|
||||
}
|
||||
|
||||
private fun startScan() {
|
||||
val scanner = adapter?.takeIf { it.isEnabled }?.bluetoothLeScanner ?: return
|
||||
if (scanning) return
|
||||
emit(TransportEvent.Status(TransportStatus.SCANNING))
|
||||
val filters = listOf(
|
||||
ScanFilter.Builder().setServiceUuid(ParcelUuid(BleProfiles.HEART_RATE_SERVICE)).build(),
|
||||
)
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
try {
|
||||
scanner.startScan(filters, settings, scanCallback)
|
||||
scanning = true
|
||||
} catch (_: SecurityException) {
|
||||
// разрешения отозвали между проверкой и стартом — статус покажет фасад
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopScan() {
|
||||
if (!scanning) return
|
||||
runCatching { adapter?.bluetoothLeScanner?.stopScan(scanCallback) }
|
||||
scanning = false
|
||||
}
|
||||
|
||||
private fun connect(device: BluetoothDevice) {
|
||||
emit(TransportEvent.Status(TransportStatus.CONNECTING))
|
||||
closeGatt()
|
||||
gatt = try {
|
||||
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||
} catch (_: SecurityException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private val gattCallback = object : BluetoothGattCallback() {
|
||||
|
||||
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
closeGatt()
|
||||
if (started) {
|
||||
emit(TransportEvent.Status(TransportStatus.LOST))
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
|
||||
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
val hrChar = g.getService(BleProfiles.HEART_RATE_SERVICE)
|
||||
?.getCharacteristic(BleProfiles.HEART_RATE_MEASUREMENT)
|
||||
if (hrChar == null) {
|
||||
// устройство не отдаёт HR-сервис — переподключение не поможет
|
||||
closeGatt()
|
||||
if (started) emit(TransportEvent.Status(TransportStatus.LOST))
|
||||
return
|
||||
}
|
||||
setDescriptor(g, hrChar, indication = true)
|
||||
emit(TransportEvent.Status(TransportStatus.CONNECTED))
|
||||
reconnectAttempt = 0
|
||||
|
||||
// батарея опциональна (Ф-3.1 «если передаётся»)
|
||||
g.getService(BleProfiles.BATTERY_SERVICE)?.getCharacteristic(BleProfiles.BATTERY_LEVEL)
|
||||
?.let { battery ->
|
||||
setDescriptor(g, battery, indication = false)
|
||||
@Suppress("DEPRECATION") // новая сигнатура — только 33+, старая работает везде
|
||||
runCatching { g.readCharacteristic(battery) }
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
|
||||
if (characteristic.uuid == BleProfiles.BATTERY_LEVEL && status == BluetoothGatt.GATT_SUCCESS) {
|
||||
characteristic.value?.firstOrNull()?.let { emit(TransportEvent.Battery(it.toInt() and 0xFF)) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicRead(
|
||||
g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int,
|
||||
) {
|
||||
if (characteristic.uuid == BleProfiles.BATTERY_LEVEL && status == BluetoothGatt.GATT_SUCCESS) {
|
||||
value.firstOrNull()?.let { emit(TransportEvent.Battery(it.toInt() and 0xFF)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||
if (characteristic.uuid == BleProfiles.HEART_RATE_MEASUREMENT) {
|
||||
characteristic.value?.let { onMeasurement(it) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(
|
||||
g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray,
|
||||
) {
|
||||
if (characteristic.uuid == BleProfiles.HEART_RATE_MEASUREMENT) onMeasurement(value)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun setDescriptor(g: BluetoothGatt, char: BluetoothGattCharacteristic, indication: Boolean) {
|
||||
val ccc = char.getDescriptor(BleProfiles.CLIENT_CONFIG) ?: return
|
||||
val value = if (indication) {
|
||||
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
|
||||
} else {
|
||||
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 33) g.writeDescriptor(ccc, value)
|
||||
else {
|
||||
ccc.value = value
|
||||
g.writeDescriptor(ccc)
|
||||
}
|
||||
}
|
||||
|
||||
private fun onMeasurement(bytes: ByteArray) {
|
||||
HrPacketParser.parse(bytes)?.let {
|
||||
emit(TransportEvent.Sample(it.bpm, System.currentTimeMillis()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (!started) return
|
||||
cancelReconnect()
|
||||
reconnectAttempt += 1
|
||||
val runnable = Runnable { if (started) startScan() }
|
||||
reconnectRunnable = runnable
|
||||
handler.postDelayed(runnable, ReconnectPolicy.nextDelayMs(reconnectAttempt))
|
||||
}
|
||||
|
||||
private fun cancelReconnect() {
|
||||
reconnectRunnable?.let { handler.removeCallbacks(it) }
|
||||
reconnectRunnable = null
|
||||
}
|
||||
|
||||
private fun closeGatt() {
|
||||
runCatching {
|
||||
gatt?.disconnect()
|
||||
gatt?.close()
|
||||
}
|
||||
gatt = null
|
||||
}
|
||||
|
||||
private fun registerReceiverOnce() {
|
||||
if (receiverRegistered) return
|
||||
val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
|
||||
runCatching { context.registerReceiver(btReceiver, filter) }
|
||||
receiverRegistered = true
|
||||
}
|
||||
|
||||
private fun emit(event: TransportEvent) {
|
||||
_events.tryEmit(event)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.watchbody.phone.watch
|
||||
|
||||
/**
|
||||
* Разбор пакета Heart Rate Measurement 0x2A37 (m12 spec 01 §3) —
|
||||
* чистая функция. Флаги (байт 0): бит 0 — формат пульса
|
||||
* (0 → uint8, 1 → uint16), бит 1 — Energy Expended (пропускается),
|
||||
* бит 2 — RR-интервалы (пропускаются). Пульс вне 30–250 — пакет
|
||||
* игнорируется (склейки/глюки датчика).
|
||||
*/
|
||||
object HrPacketParser {
|
||||
|
||||
data class HrPacket(val bpm: Int, val energyPresent: Boolean, val rrCount: Int)
|
||||
|
||||
fun parse(bytes: ByteArray): HrPacket? {
|
||||
if (bytes.size < 2) return null
|
||||
val flags = bytes[0].toInt() and 0xFF
|
||||
val uint16 = flags and 0x01 != 0
|
||||
var offset = 1
|
||||
val bpm = if (uint16) {
|
||||
if (bytes.size < 3) return null
|
||||
offset = 3
|
||||
(bytes[1].toInt() and 0xFF) or ((bytes[2].toInt() and 0xFF) shl 8)
|
||||
} else {
|
||||
offset = 2
|
||||
bytes[1].toInt() and 0xFF
|
||||
}
|
||||
if (bpm < 30 || bpm > 250) return null
|
||||
val energyPresent = flags and 0x02 != 0
|
||||
val rrPresent = flags and 0x04 != 0
|
||||
if (energyPresent) offset += 2
|
||||
val rrCount = if (rrPresent) (bytes.size - offset).coerceAtLeast(0) / 2 else 0
|
||||
return HrPacket(bpm, energyPresent, rrCount)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package com.watchbody.phone.watch
|
||||
|
||||
/**
|
||||
* Буфер пульса в памяти (m12 spec 02) — pure JVM, в БД не пишется
|
||||
* (инвариант 3: история пульса — из .fit, m07/m08). Время —
|
||||
* абсолютные миллисекунды.
|
||||
*/
|
||||
class HrSampleStore {
|
||||
|
||||
private val samples = ArrayList<Pair<Long, Int>>() // (atMs, bpm)
|
||||
|
||||
val lastSampleAtMs: Long? get() = samples.lastOrNull()?.first
|
||||
|
||||
/** Диапазон 30–250 — двойная защита (первая — парсер, spec 01 §3). */
|
||||
fun add(bpm: Int, atMs: Long) {
|
||||
if (bpm in 30..250) samples.add(atMs to bpm)
|
||||
}
|
||||
|
||||
/** Последний сэмпл не старше [staleAfterMs]; иначе null («нет данных»). */
|
||||
fun current(nowMs: Long, staleAfterMs: Long = 10_000): Int? =
|
||||
samples.lastOrNull { nowMs - it.first in 0..staleAfterMs }?.second
|
||||
|
||||
/** Среднее (Int, вниз) по окну [fromMs, toMs]; пусто → null. */
|
||||
fun average(fromMs: Long, toMs: Long): Int? {
|
||||
val win = samples.filter { it.first in fromMs..toMs }
|
||||
if (win.isEmpty()) return null
|
||||
return win.sumOf { it.second } / win.size
|
||||
}
|
||||
|
||||
/** Сэмплы окна в порядке поступления — линия последнего подхода (Ф-3.2). */
|
||||
fun series(fromMs: Long, toMs: Long): List<Pair<Long, Int>> =
|
||||
samples.filter { it.first in fromMs..toMs }
|
||||
|
||||
fun clear() = samples.clear()
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
package com.watchbody.phone.watch
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Фасад канала часов (m12 spec 03). Инвариант 3: канал ничего не пишет
|
||||
* в LocalDb/sync — единственная точка записи `avg_hr` через
|
||||
* `WorkoutCore.finishWorkout(avgHr)` со стороны VM.
|
||||
*/
|
||||
enum class WatchStatus { NOT_SELECTED, NO_PERMISSION, BT_OFF, SCANNING, CONNECTED, NO_SIGNAL }
|
||||
|
||||
data class WatchChannelState(
|
||||
val status: WatchStatus,
|
||||
val hr: Int? = null, // свежий сэмпл (≤10 с) или null
|
||||
val battery: Int? = null, // последний Battery-событие или null (Ф-3.1 «если передаётся»)
|
||||
)
|
||||
|
||||
/** Проверки окружения; Android-реализация — WatchPermissions (m12 spec 03 §3). */
|
||||
interface ChannelEnvironment {
|
||||
fun hasBluetoothPermissions(): Boolean
|
||||
fun isBluetoothEnabled(): Boolean
|
||||
}
|
||||
|
||||
class WatchChannel(
|
||||
private val transport: WatchTransport,
|
||||
private val store: HrSampleStore,
|
||||
private val environment: ChannelEnvironment,
|
||||
private val scope: CoroutineScope,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
private val staleAfterMs: Long = 10_000,
|
||||
) {
|
||||
|
||||
private val _state = MutableStateFlow(WatchChannelState(WatchStatus.NOT_SELECTED))
|
||||
val state: StateFlow<WatchChannelState> = _state
|
||||
|
||||
private var workoutStartedAtMs: Long? = null
|
||||
private var address: String = ""
|
||||
private var bound = false
|
||||
private var ticker: Job? = null
|
||||
private var collectJob: Job? = null
|
||||
|
||||
init {
|
||||
collectJob = scope.launch { transport.events.collect { onEvent(it) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Открытие экрана «Тренировка» (дата старта) или «Допуск» (null).
|
||||
* Идемпотентен; повторный вызов (смена адреса/возврат разрешения)
|
||||
* переоценивает статус.
|
||||
*/
|
||||
fun bind(workoutStartedAtMs: Long?, deviceAddress: String) {
|
||||
this.workoutStartedAtMs = workoutStartedAtMs
|
||||
this.address = deviceAddress
|
||||
bound = true
|
||||
applyPreconditions()
|
||||
startTicker()
|
||||
}
|
||||
|
||||
fun unbind() {
|
||||
bound = false
|
||||
ticker?.cancel()
|
||||
ticker = null
|
||||
transport.stop()
|
||||
_state.value = WatchChannelState(WatchStatus.NOT_SELECTED)
|
||||
}
|
||||
|
||||
/** Полная остановка канала (тесты/закрытие приложения): коллектор + тикер + транспорт. */
|
||||
fun dispose() {
|
||||
unbind()
|
||||
collectJob?.cancel()
|
||||
collectJob = null
|
||||
}
|
||||
|
||||
/** NO_PERMISSION/NOT_SELECTED — без запуска транспорта (🛡 не блок). */
|
||||
private fun applyPreconditions() {
|
||||
val blocked = when {
|
||||
!environment.hasBluetoothPermissions() -> WatchStatus.NO_PERMISSION
|
||||
address.isBlank() -> WatchStatus.NOT_SELECTED
|
||||
else -> null
|
||||
}
|
||||
if (blocked != null) {
|
||||
transport.stop()
|
||||
_state.value = WatchChannelState(blocked)
|
||||
return
|
||||
}
|
||||
// при выключенном адаптере клиент эмитит BT_OFF и держит приёмник
|
||||
// состояния Bluetooth — включение перезапустит скан (spec 01 §2)
|
||||
transport.start(address)
|
||||
}
|
||||
|
||||
private fun onEvent(event: TransportEvent) {
|
||||
when (event) {
|
||||
is TransportEvent.Sample -> {
|
||||
store.add(event.bpm, event.atMs)
|
||||
val hr = if (_state.value.status == WatchStatus.CONNECTED) store.current(now(), staleAfterMs) else null
|
||||
_state.value = _state.value.copy(hr = hr)
|
||||
}
|
||||
is TransportEvent.Battery ->
|
||||
_state.value = _state.value.copy(battery = event.percent)
|
||||
|
||||
is TransportEvent.Status -> if (bound) {
|
||||
val mapped = when (event.status) {
|
||||
TransportStatus.SCANNING, TransportStatus.CONNECTING -> WatchStatus.SCANNING
|
||||
TransportStatus.CONNECTED -> WatchStatus.CONNECTED
|
||||
TransportStatus.LOST -> WatchStatus.NO_SIGNAL
|
||||
TransportStatus.BT_OFF -> WatchStatus.BT_OFF
|
||||
TransportStatus.IDLE -> return // stop() — экран уже ушёл
|
||||
}
|
||||
val hr = if (mapped == WatchStatus.CONNECTED) store.current(now(), staleAfterMs) else null
|
||||
_state.value = _state.value.copy(status = mapped, hr = hr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Устаревание пульса (>10 с тишины при CONNECTED) без новых сэмплов. */
|
||||
private fun startTicker() {
|
||||
ticker?.cancel()
|
||||
ticker = scope.launch {
|
||||
while (true) {
|
||||
delay(2_000)
|
||||
if (_state.value.status == WatchStatus.CONNECTED) {
|
||||
val hr = store.current(now(), staleAfterMs)
|
||||
if (_state.value.hr != hr) _state.value = _state.value.copy(hr = hr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Среднее за окно тренировки — в finishWorkout(avgHr) (цепочка m09). */
|
||||
fun avgHr(): Int? = store.average(workoutStartedAtMs ?: 0, now())
|
||||
|
||||
/** Свежий сэмпл для допуска (Ф-13.2, ≤30 с). */
|
||||
fun freshHr(maxAgeMs: Long = 30_000): Int? = store.current(now(), maxAgeMs)
|
||||
|
||||
/** Линия последнего подхода (Ф-3.2) — от последнего logSet до сейчас. */
|
||||
fun series(fromMs: Long): List<Pair<Long, Int>> = store.series(fromMs, now())
|
||||
|
||||
/** Сброс буфера — VM после финишированной тренировки (spec 03 §2). */
|
||||
fun clearSamples() = store.clear()
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package com.watchbody.phone.watch
|
||||
|
||||
import com.watchbody.phone.core.ZoneCalculator
|
||||
|
||||
/**
|
||||
* Тексты и цветовые токены канала часов (m12 spec 03 §1, 04 §1–2) —
|
||||
* чистые функции; 🛡 цвет всегда дублируется текстом.
|
||||
*/
|
||||
enum class StatusColor { GREEN, RED, GRAY }
|
||||
|
||||
enum class HrZoneColor { BLUE, GREEN, YELLOW, RED }
|
||||
|
||||
data class HrZoneInfo(val word: String, val color: HrZoneColor)
|
||||
|
||||
object WatchFormat {
|
||||
|
||||
/** Текст статуса часов (без префикса «часы: »), spec 03 §1. */
|
||||
fun watchStatusText(state: WatchChannelState): String {
|
||||
val battery = state.battery?.let { " (заряд $it %)" }.orEmpty()
|
||||
return when (state.status) {
|
||||
WatchStatus.CONNECTED ->
|
||||
if (state.hr != null) "связь есть$battery" else "подключены, нет сигнала"
|
||||
WatchStatus.NO_SIGNAL -> "нет связи"
|
||||
WatchStatus.SCANNING -> "поиск…"
|
||||
WatchStatus.NOT_SELECTED -> "не выбраны"
|
||||
WatchStatus.BT_OFF -> "Bluetooth выключен"
|
||||
WatchStatus.NO_PERMISSION -> "нет разрешения"
|
||||
}
|
||||
}
|
||||
|
||||
fun statusColor(state: WatchChannelState): StatusColor = when {
|
||||
state.status == WatchStatus.CONNECTED && state.hr != null -> StatusColor.GREEN
|
||||
state.status == WatchStatus.NO_SIGNAL -> StatusColor.RED
|
||||
else -> StatusColor.GRAY
|
||||
}
|
||||
|
||||
/** Зона текущего пульса — слова те же, что на «Профиле» (m10, Ф-6.2). */
|
||||
fun hrZone(bpm: Int, zones: ZoneCalculator.Zones): HrZoneInfo = when {
|
||||
bpm < zones.z2 -> HrZoneInfo("recovery", HrZoneColor.BLUE)
|
||||
bpm < zones.z3 -> HrZoneInfo("лёгкая", HrZoneColor.GREEN)
|
||||
bpm < zones.z4 -> HrZoneInfo("средняя", HrZoneColor.YELLOW)
|
||||
else -> HrZoneInfo("высокая", HrZoneColor.RED)
|
||||
}
|
||||
|
||||
/** Подпись кнопки «Взять с часов» на допуске (spec 04 §2). */
|
||||
fun admissionHint(connected: Boolean, freshHr: Int?): String = when {
|
||||
freshHr != null -> "с часов: $freshHr уд/мин"
|
||||
connected -> "нет свежего значения"
|
||||
else -> "часы не связаны"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.watchbody.phone.watch
|
||||
|
||||
import android.Manifest
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.core.content.ContextCompat
|
||||
|
||||
/**
|
||||
* Разрешения и состояние адаптера (m12 spec 01 §6, 03 §3).
|
||||
* 12+ — BLUETOOTH_SCAN (neverForLocation) + BLUETOOTH_CONNECT;
|
||||
* 8–11 — ACCESS_FINE_LOCATION. Отказ не блокирует экраны (🛡 инвариант 2).
|
||||
*/
|
||||
object WatchPermissions {
|
||||
|
||||
fun required(): Array<String> = if (Build.VERSION.SDK_INT >= 31) {
|
||||
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
|
||||
fun granted(context: Context): Boolean = required().all {
|
||||
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
/** Android-реализация окружения фасада (m12 spec 03 §3). */
|
||||
class AndroidChannelEnvironment(private val context: Context) : ChannelEnvironment {
|
||||
|
||||
override fun hasBluetoothPermissions(): Boolean = WatchPermissions.granted(context)
|
||||
|
||||
override fun isBluetoothEnabled(): Boolean =
|
||||
(context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
|
||||
?.adapter?.isEnabled == true
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package com.watchbody.phone.watch
|
||||
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Платформо-независимый транспорт канала часов (m12 spec 01 §1,
|
||||
* инвариант 6 — мультичасовая инфраструктура): BLE-реализация первая,
|
||||
* CIQ 5+ / ANT+ подключаются реализациями интерфейса без правок
|
||||
* фасада и UI.
|
||||
*/
|
||||
enum class TransportStatus {
|
||||
IDLE, SCANNING, CONNECTING, CONNECTED, LOST, BT_OFF,
|
||||
}
|
||||
|
||||
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 {
|
||||
/** События статуса/пульса/батареи; Sample — только в CONNECTED. */
|
||||
val events: SharedFlow<TransportEvent>
|
||||
|
||||
/** Запуск обмена с устройством по адресу (выбор — в «Настройках»). */
|
||||
fun start(deviceAddress: String)
|
||||
|
||||
/** Полная остановка (скан, GATT, приёмник); повторный start — с SCANNING. */
|
||||
fun stop()
|
||||
}
|
||||
|
||||
/** Паузы автопереподключения: 1 c → 5 c → 30 c (далее каждые 30 c), m12 spec 01 §4. */
|
||||
object ReconnectPolicy {
|
||||
fun nextDelayMs(attempt: Int): Long = when (attempt) {
|
||||
1 -> 1_000
|
||||
2 -> 5_000
|
||||
else -> 30_000
|
||||
}
|
||||
}
|
||||
|
||||
/** Стандартные профили GATT — только они, без платформо-специфики (инвариант 6). */
|
||||
object BleProfiles {
|
||||
val HEART_RATE_SERVICE: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
|
||||
val HEART_RATE_MEASUREMENT: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
|
||||
val BATTERY_SERVICE: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
|
||||
val BATTERY_LEVEL: UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
|
||||
val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
package com.watchbody.phone
|
||||
|
||||
import com.watchbody.phone.watch.HrPacketParser
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Разбор 0x2A37 (m12 spec 01 §5): флаги, 8/16 бит, Energy/RR, диапазон 30–250. */
|
||||
class HrPacketParserTest {
|
||||
|
||||
@Test
|
||||
fun `uint8 без доп полей`() {
|
||||
val p = HrPacketParser.parse(byteArrayOf(0x00, 120))!!
|
||||
assertEquals(120, p.bpm)
|
||||
assertFalse(p.energyPresent)
|
||||
assertEquals(0, p.rrCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uint16 формат`() {
|
||||
val p = HrPacketParser.parse(byteArrayOf(0x01, 0xB4.toByte(), 0x00))!!
|
||||
assertEquals(180, p.bpm)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `energy expended пропускается`() {
|
||||
val p = HrPacketParser.parse(byteArrayOf(0x02, 100, 0x10, 0x00))!!
|
||||
assertEquals(100, p.bpm)
|
||||
assertTrue(p.energyPresent)
|
||||
assertEquals(0, p.rrCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rr интервалы пропускаются`() {
|
||||
val p = HrPacketParser.parse(byteArrayOf(0x04, 90, 0x88.toByte(), 0x02, 0x90.toByte(), 0x02))!!
|
||||
assertEquals(90, p.bpm)
|
||||
assertEquals(2, p.rrCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `energy и rr вместе`() {
|
||||
val p = HrPacketParser.parse(
|
||||
byteArrayOf(0x06, 90, 0x10, 0x00, 0x88.toByte(), 0x02, 0x90.toByte(), 0x02),
|
||||
)!!
|
||||
assertEquals(90, p.bpm)
|
||||
assertTrue(p.energyPresent)
|
||||
assertEquals(2, p.rrCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `короткий пакет - null`() {
|
||||
assertNull(HrPacketParser.parse(byteArrayOf(0x00)))
|
||||
assertNull(HrPacketParser.parse(byteArrayOf()))
|
||||
assertNull(HrPacketParser.parse(byteArrayOf(0x01, 0xB4.toByte()))) // uint16 без байта
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `диапазон 30-250`() {
|
||||
assertNull(HrPacketParser.parse(byteArrayOf(0x00, 0))) // 0 — нет данных
|
||||
assertNull(HrPacketParser.parse(byteArrayOf(0x00, 29)))
|
||||
assertNull(HrPacketParser.parse(byteArrayOf(0x00, 251.toByte())))
|
||||
assertNull(HrPacketParser.parse(byteArrayOf(0x00, 0xFF.toByte()))) // 255
|
||||
assertEquals(30, HrPacketParser.parse(byteArrayOf(0x00, 30))!!.bpm)
|
||||
assertEquals(250, HrPacketParser.parse(byteArrayOf(0x00, 0xFA.toByte()))!!.bpm)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
package com.watchbody.phone
|
||||
|
||||
import com.watchbody.phone.watch.HrSampleStore
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** Буфер пульса (m12 spec 02 §3): диапазон, устаревание, окна среднего/серии. */
|
||||
class HrSampleStoreTest {
|
||||
|
||||
@Test
|
||||
fun `фильтрация диапазона при add`() {
|
||||
val s = HrSampleStore()
|
||||
s.add(29, 0); s.add(251, 0); s.add(30, 0); s.add(250, 1_000)
|
||||
assertEquals(2, s.series(0, 10_000).size)
|
||||
assertEquals(30, s.current(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `current - свежесть 10 с включительно`() {
|
||||
val s = HrSampleStore()
|
||||
s.add(120, 10_000)
|
||||
assertEquals(120, s.current(20_000)) // ровно 10 с
|
||||
assertNull(s.current(20_001)) // 10,001 с
|
||||
assertNull(s.current(8_000)) // будущее не считается
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `average - пусто и округление вниз`() {
|
||||
val s = HrSampleStore()
|
||||
assertNull(s.average(0, 1_000))
|
||||
s.add(100, 100); s.add(100, 200); s.add(102, 300)
|
||||
assertEquals(100, s.average(0, 1_000)) // 302/3 = 100,(6)
|
||||
s.add(127, 400); s.add(128, 500)
|
||||
assertEquals(111, s.average(0, 1_000)) // 557/5 = 111,4
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `average - окно вырезает`() {
|
||||
val s = HrSampleStore()
|
||||
s.add(100, 500); s.add(140, 1_500); s.add(180, 2_500)
|
||||
assertEquals(140, s.average(1_000, 2_000))
|
||||
assertEquals(160, s.average(1_500, 3_000)) // включительно по границам
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `series - порядок и границы окна`() {
|
||||
val s = HrSampleStore()
|
||||
s.add(100, 100); s.add(110, 200); s.add(120, 300)
|
||||
// пары (atMs, bpm), границы включительно
|
||||
assertEquals(listOf(100L to 100, 200L to 110), s.series(100, 200).map { it.first to it.second })
|
||||
assertEquals(listOf(120), s.series(300, 400).map { it.second })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear обнуляет`() {
|
||||
val s = HrSampleStore()
|
||||
s.add(100, 0)
|
||||
s.clear()
|
||||
assertNull(s.current(0))
|
||||
assertNull(s.lastSampleAtMs)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
package com.watchbody.phone
|
||||
|
||||
import com.watchbody.phone.watch.ChannelEnvironment
|
||||
import com.watchbody.phone.watch.HrSampleStore
|
||||
import com.watchbody.phone.watch.ReconnectPolicy
|
||||
import com.watchbody.phone.watch.TransportEvent
|
||||
import com.watchbody.phone.watch.TransportStatus
|
||||
import com.watchbody.phone.watch.WatchChannel
|
||||
import com.watchbody.phone.watch.WatchStatus
|
||||
import com.watchbody.phone.watch.WatchTransport
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** Фасад канала часов на фейке транспорта (m12 spec 03 §6) + ReconnectPolicy. */
|
||||
class WatchChannelTest {
|
||||
|
||||
private class FakeTransport : WatchTransport {
|
||||
val flow = MutableSharedFlow<TransportEvent>(extraBufferCapacity = 64)
|
||||
override val events: SharedFlow<TransportEvent> = flow
|
||||
val starts = mutableListOf<String>()
|
||||
var stops = 0
|
||||
override fun start(deviceAddress: String) {
|
||||
starts += deviceAddress
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
stops += 1
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeEnv(
|
||||
var permissions: Boolean = true,
|
||||
var btEnabled: Boolean = true,
|
||||
) : ChannelEnvironment {
|
||||
override fun hasBluetoothPermissions() = permissions
|
||||
override fun isBluetoothEnabled() = btEnabled
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `пустой адрес - NOT_SELECTED без старта транспорта`() = runTest {
|
||||
val t = FakeTransport()
|
||||
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { 0 })
|
||||
runCurrent()
|
||||
ch.bind(workoutStartedAtMs = 1_000, deviceAddress = "")
|
||||
runCurrent()
|
||||
assertEquals(WatchStatus.NOT_SELECTED, ch.state.value.status)
|
||||
assertEquals(0, t.starts.size)
|
||||
ch.dispose()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `нет разрешения - NO_PERMISSION без старта`() = runTest {
|
||||
val t = FakeTransport()
|
||||
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(permissions = false), this, now = { 0 })
|
||||
runCurrent()
|
||||
ch.bind(null, "AA:BB")
|
||||
runCurrent()
|
||||
assertEquals(WatchStatus.NO_PERMISSION, ch.state.value.status)
|
||||
assertEquals(0, t.starts.size)
|
||||
ch.dispose()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `скан - связь - сэмпл - потеря`() = runTest {
|
||||
var nowMs = 0L
|
||||
val t = FakeTransport()
|
||||
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { nowMs })
|
||||
runCurrent()
|
||||
ch.bind(workoutStartedAtMs = 1_000, deviceAddress = "AA:BB")
|
||||
runCurrent()
|
||||
assertEquals(listOf("AA:BB"), t.starts)
|
||||
|
||||
t.flow.tryEmit(TransportEvent.Status(TransportStatus.SCANNING))
|
||||
runCurrent()
|
||||
assertEquals(WatchStatus.SCANNING, ch.state.value.status)
|
||||
|
||||
t.flow.tryEmit(TransportEvent.Status(TransportStatus.CONNECTED))
|
||||
runCurrent()
|
||||
assertEquals(WatchStatus.CONNECTED, ch.state.value.status)
|
||||
|
||||
nowMs = 1_500
|
||||
t.flow.tryEmit(TransportEvent.Sample(120, nowMs))
|
||||
runCurrent()
|
||||
assertEquals(120, ch.state.value.hr)
|
||||
|
||||
t.flow.tryEmit(TransportEvent.Battery(80))
|
||||
runCurrent()
|
||||
assertEquals(80, ch.state.value.battery)
|
||||
|
||||
t.flow.tryEmit(TransportEvent.Status(TransportStatus.LOST))
|
||||
runCurrent()
|
||||
assertEquals(WatchStatus.NO_SIGNAL, ch.state.value.status)
|
||||
assertNull(ch.state.value.hr) // нет связи — пульс-прочерк
|
||||
ch.dispose()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `среднее за окно тренировки - до старта не входит`() = runTest {
|
||||
var nowMs = 3_000L
|
||||
val t = FakeTransport()
|
||||
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { nowMs })
|
||||
runCurrent()
|
||||
ch.bind(workoutStartedAtMs = 1_000, deviceAddress = "AA:BB")
|
||||
runCurrent()
|
||||
t.flow.tryEmit(TransportEvent.Status(TransportStatus.CONNECTED))
|
||||
t.flow.tryEmit(TransportEvent.Sample(200, 500)) // до тренировки
|
||||
t.flow.tryEmit(TransportEvent.Sample(100, 1_500)) // в окне
|
||||
t.flow.tryEmit(TransportEvent.Sample(110, 2_500)) // в окне
|
||||
runCurrent()
|
||||
assertEquals(105, ch.avgHr())
|
||||
ch.dispose()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `устаревание пульса - тикер 2 с`() = runTest {
|
||||
var nowMs = 0L
|
||||
val t = FakeTransport()
|
||||
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { nowMs })
|
||||
runCurrent()
|
||||
ch.bind(0, "AA:BB")
|
||||
t.flow.tryEmit(TransportEvent.Status(TransportStatus.CONNECTED))
|
||||
t.flow.tryEmit(TransportEvent.Sample(120, 0))
|
||||
runCurrent()
|
||||
assertEquals(120, ch.state.value.hr)
|
||||
|
||||
nowMs = 10_001 // тишина > 10 с
|
||||
advanceTimeBy(2_000)
|
||||
runCurrent() // задача ровно на границе окна выполняется на текущем такте
|
||||
assertNull(ch.state.value.hr)
|
||||
assertEquals(WatchStatus.CONNECTED, ch.state.value.status)
|
||||
ch.dispose()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `freshHr 30 с для допуска`() = runTest {
|
||||
var nowMs = 0L
|
||||
val t = FakeTransport()
|
||||
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { nowMs })
|
||||
runCurrent()
|
||||
ch.bind(null, "AA:BB")
|
||||
t.flow.tryEmit(TransportEvent.Status(TransportStatus.CONNECTED))
|
||||
t.flow.tryEmit(TransportEvent.Sample(72, 0))
|
||||
runCurrent()
|
||||
assertEquals(72, ch.freshHr())
|
||||
nowMs = 30_000
|
||||
assertEquals(72, ch.freshHr()) // ровно 30 с
|
||||
nowMs = 30_001
|
||||
assertNull(ch.freshHr())
|
||||
ch.dispose()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unbind останавливает транспорт`() = runTest {
|
||||
val t = FakeTransport()
|
||||
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { 0 })
|
||||
runCurrent()
|
||||
ch.bind(0, "AA:BB")
|
||||
runCurrent()
|
||||
ch.unbind()
|
||||
assertEquals(1, t.stops)
|
||||
assertEquals(WatchStatus.NOT_SELECTED, ch.state.value.status)
|
||||
ch.dispose()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `повторный bind меняет адрес`() = runTest {
|
||||
val t = FakeTransport()
|
||||
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { 0 })
|
||||
runCurrent()
|
||||
ch.bind(0, "AA:BB")
|
||||
ch.bind(0, "CC:DD")
|
||||
runCurrent()
|
||||
assertEquals(listOf("AA:BB", "CC:DD"), t.starts)
|
||||
ch.dispose()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ReconnectPolicy 1-5-30-30`() {
|
||||
assertEquals(1_000L, ReconnectPolicy.nextDelayMs(1))
|
||||
assertEquals(5_000L, ReconnectPolicy.nextDelayMs(2))
|
||||
assertEquals(30_000L, ReconnectPolicy.nextDelayMs(3))
|
||||
assertEquals(30_000L, ReconnectPolicy.nextDelayMs(10))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
package com.watchbody.phone
|
||||
|
||||
import com.watchbody.phone.core.ZoneCalculator
|
||||
import com.watchbody.phone.watch.HrZoneColor
|
||||
import com.watchbody.phone.watch.StatusColor
|
||||
import com.watchbody.phone.watch.WatchChannelState
|
||||
import com.watchbody.phone.watch.WatchFormat
|
||||
import com.watchbody.phone.watch.WatchStatus
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/** Тексты/зоны канала часов (m12 spec 03 §1, 04 §1–2, §6). */
|
||||
class WatchFormatTest {
|
||||
|
||||
private fun state(status: WatchStatus, hr: Int? = null, battery: Int? = null) =
|
||||
WatchChannelState(status, hr, battery)
|
||||
|
||||
@Test
|
||||
fun `тексты статусов`() {
|
||||
assertEquals("связь есть (заряд 80 %)", WatchFormat.watchStatusText(state(WatchStatus.CONNECTED, 120, 80)))
|
||||
assertEquals("связь есть", WatchFormat.watchStatusText(state(WatchStatus.CONNECTED, 120, null)))
|
||||
assertEquals("подключены, нет сигнала", WatchFormat.watchStatusText(state(WatchStatus.CONNECTED, null, 80)))
|
||||
assertEquals("нет связи", WatchFormat.watchStatusText(state(WatchStatus.NO_SIGNAL)))
|
||||
assertEquals("поиск…", WatchFormat.watchStatusText(state(WatchStatus.SCANNING)))
|
||||
assertEquals("не выбраны", WatchFormat.watchStatusText(state(WatchStatus.NOT_SELECTED)))
|
||||
assertEquals("Bluetooth выключен", WatchFormat.watchStatusText(state(WatchStatus.BT_OFF)))
|
||||
assertEquals("нет разрешения", WatchFormat.watchStatusText(state(WatchStatus.NO_PERMISSION)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `цвет статуса дублируется текстом`() {
|
||||
assertEquals(StatusColor.GREEN, WatchFormat.statusColor(state(WatchStatus.CONNECTED, 120)))
|
||||
assertEquals(StatusColor.RED, WatchFormat.statusColor(state(WatchStatus.NO_SIGNAL)))
|
||||
assertEquals(StatusColor.GRAY, WatchFormat.statusColor(state(WatchStatus.CONNECTED, null)))
|
||||
assertEquals(StatusColor.GRAY, WatchFormat.statusColor(state(WatchStatus.SCANNING)))
|
||||
assertEquals(StatusColor.GRAY, WatchFormat.statusColor(state(WatchStatus.NOT_SELECTED)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `зоны пульса - слова как на профиле`() {
|
||||
val z = ZoneCalculator.zones(180) // z2=90, z3=126, z4=153
|
||||
assertEquals("recovery" to HrZoneColor.BLUE, with(WatchFormat.hrZone(89, z)) { word to color })
|
||||
assertEquals("лёгкая" to HrZoneColor.GREEN, with(WatchFormat.hrZone(90, z)) { word to color })
|
||||
assertEquals("лёгкая" to HrZoneColor.GREEN, with(WatchFormat.hrZone(125, z)) { word to color })
|
||||
assertEquals("средняя" to HrZoneColor.YELLOW, with(WatchFormat.hrZone(126, z)) { word to color })
|
||||
assertEquals("высокая" to HrZoneColor.RED, with(WatchFormat.hrZone(153, z)) { word to color })
|
||||
assertEquals("высокая" to HrZoneColor.RED, with(WatchFormat.hrZone(180, z)) { word to color })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `подпись кнопки взятия с часов`() {
|
||||
assertEquals("с часов: 72 уд/мин", WatchFormat.admissionHint(connected = true, freshHr = 72))
|
||||
assertEquals("нет свежего значения", WatchFormat.admissionHint(connected = true, freshHr = null))
|
||||
assertEquals("часы не связаны", WatchFormat.admissionHint(connected = false, freshHr = null))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue