diff --git a/1-docs/0-mng/plan.md b/1-docs/0-mng/plan.md index 0319a62..d83868b 100644 --- a/1-docs/0-mng/plan.md +++ b/1-docs/0-mng/plan.md @@ -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 параллельно diff --git a/1-docs/1-reqs/requirements.md b/1-docs/1-reqs/requirements.md index ab736c8..60f1237 100644 --- a/1-docs/1-reqs/requirements.md +++ b/1-docs/1-reqs/requirements.md @@ -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) | diff --git a/1-docs/2-arch/architecture.md b/1-docs/2-arch/architecture.md index 82b1023..ccc9f32 100644 --- a/1-docs/2-arch/architecture.md +++ b/1-docs/2-arch/architecture.md @@ -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 | diff --git a/1-docs/3-tech/tech-requirements.md b/1-docs/3-tech/tech-requirements.md index 6d0f548..689bd31 100644 --- a/1-docs/3-tech/tech-requirements.md +++ b/1-docs/3-tech/tech-requirements.md @@ -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. diff --git a/1-docs/4-modules/m12-watch-channel/SKILL.md b/1-docs/4-modules/m12-watch-channel/SKILL.md new file mode 100644 index 0000000..cd82f33 --- /dev/null +++ b/1-docs/4-modules/m12-watch-channel/SKILL.md @@ -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/) diff --git a/1-docs/4-modules/m12-watch-channel/api_specs/01-watch-transport-ble.md b/1-docs/4-modules/m12-watch-channel/api_specs/01-watch-transport-ble.md new file mode 100644 index 0000000..37a2792 --- /dev/null +++ b/1-docs/4-modules/m12-watch-channel/api_specs/01-watch-transport-ble.md @@ -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 + 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`, не блок. diff --git a/1-docs/4-modules/m12-watch-channel/api_specs/02-hr-sample-store.md b/1-docs/4-modules/m12-watch-channel/api_specs/02-hr-sample-store.md new file mode 100644 index 0000000..2b1b9a0 --- /dev/null +++ b/1-docs/4-modules/m12-watch-channel/api_specs/02-hr-sample-store.md @@ -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> // (atMs, bpm) + fun clear() // сброс (новая тренировка) + val lastSampleAtMs: Long? +} +``` + +- время — абсолютные миллисекунды (`System.currentTimeMillis()` + передаётся вызывающим; в тестах — фикссированные значения); +- хранение: весь список сэмплов сессии (`ArrayList>`) + — при 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` обнуляет всё. diff --git a/1-docs/4-modules/m12-watch-channel/api_specs/03-watch-channel-facade.md b/1-docs/4-modules/m12-watch-channel/api_specs/03-watch-channel-facade.md new file mode 100644 index 0000000..07e7d62 --- /dev/null +++ b/1-docs/4-modules/m12-watch-channel/api_specs/03-watch-channel-facade.md @@ -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 + 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 + (ручной сценарий). diff --git a/1-docs/4-modules/m12-watch-channel/api_specs/04-ui-integration.md b/1-docs/4-modules/m12-watch-channel/api_specs/04-ui-integration.md new file mode 100644 index 0000000..2385cc7 --- /dev/null +++ b/1-docs/4-modules/m12-watch-channel/api_specs/04-ui-integration.md @@ -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 %)» · «пульс: » · + «сеть: онлайн/офлайн» (сеть — как в 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 + + + + + +``` + +(`BLUETOOTH_CONNECT` — без `neverForLocation`, только SCAN.) + +## 6. Тесты (pure JVM) + +- форматирование статус-строки: все WatchStatus × батарея + есть/нет × пульс есть/устарел (тексты из спец 03 §1, заряд + опускается при null); +- зона пульса → слово-зона и цветовой токен (границы 50/70/85 % + от Hr.MaxBpm по ZoneCalculator); +- кнопка «Взять с часов»: active/inactive по freshHr, подстановка + значения; +- логика «Больше не спрашивать» — на уровне VM-флага (системный + диалог — ручная приёмка); +- Compose-иерархия — без unit (ручной смок владельца, критерий 3). diff --git a/1-docs/4-modules/m12-watch-channel/module_reqs.md b/1-docs/4-modules/m12-watch-channel/module_reqs.md new file mode 100644 index 0000000..2d960ac --- /dev/null +++ b/1-docs/4-modules/m12-watch-channel/module_reqs.md @@ -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 в телах разделов, +версии поднимутся на ближайшем ревью). diff --git a/1-docs/4-modules/m12-watch-channel/readme.md b/1-docs/4-modules/m12-watch-channel/readme.md new file mode 100644 index 0000000..eaa28d4 --- /dev/null +++ b/1-docs/4-modules/m12-watch-channel/readme.md @@ -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 приёмки на + стороне владельца; логика покрыта фейками и байтовыми фикстурами. diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/AndroidManifest.xml b/3-app/4-phone/Phone.WatchBody/app/src/main/AndroidManifest.xml index 6e03d79..4c0e4f8 100644 --- a/3-app/4-phone/Phone.WatchBody/app/src/main/AndroidManifest.xml +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/AndroidManifest.xml @@ -4,6 +4,18 @@ + + + + + + = + 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(null) } var bpDias by remember { mutableStateOf(null) } var hrSitting by remember { mutableStateOf(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 = { diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/SettingsScreen.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/SettingsScreen.kt index 6e4d3a5..310f5b9 100644 --- a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/SettingsScreen.kt +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/SettingsScreen.kt @@ -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(null) + private set + var watchScanResults by mutableStateOf>>(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 записей", diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/WorkoutScreen.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/WorkoutScreen.kt index aab96d7..14371be 100644 --- a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/WorkoutScreen.kt +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/WorkoutScreen.kt @@ -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 = container.watchChannel.state + + /** Линия последнего подхода (Ф-3.2) — от последнего logSet до сейчас. */ + var hrSeries by mutableStateOf>>(emptyList()) + private set + var hrZones by mutableStateOf(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>) { + 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 diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/DeviceScanner.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/DeviceScanner.kt new file mode 100644 index 0000000..5bd76c9 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/DeviceScanner.kt @@ -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>) -> Unit) { + val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter + if (adapter == null || !adapter.isEnabled) { + onDone(emptyList()) + return + } + val found = LinkedHashMap() // адрес → имя + 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) + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/HrBleClient.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/HrBleClient.kt new file mode 100644 index 0000000..9a3adbd --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/HrBleClient.kt @@ -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( + extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override val events: SharedFlow = _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) + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/HrPacketParser.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/HrPacketParser.kt new file mode 100644 index 0000000..c12c376 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/HrPacketParser.kt @@ -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) + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/HrSampleStore.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/HrSampleStore.kt new file mode 100644 index 0000000..14a9851 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/HrSampleStore.kt @@ -0,0 +1,35 @@ +package com.watchbody.phone.watch + +/** + * Буфер пульса в памяти (m12 spec 02) — pure JVM, в БД не пишется + * (инвариант 3: история пульса — из .fit, m07/m08). Время — + * абсолютные миллисекунды. + */ +class HrSampleStore { + + private val samples = ArrayList>() // (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> = + samples.filter { it.first in fromMs..toMs } + + fun clear() = samples.clear() +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/WatchChannel.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/WatchChannel.kt new file mode 100644 index 0000000..ebd9811 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/WatchChannel.kt @@ -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 = _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> = store.series(fromMs, now()) + + /** Сброс буфера — VM после финишированной тренировки (spec 03 §2). */ + fun clearSamples() = store.clear() +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/WatchFormat.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/WatchFormat.kt new file mode 100644 index 0000000..e9d18c9 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/WatchFormat.kt @@ -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 -> "часы не связаны" + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/WatchPermissions.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/WatchPermissions.kt new file mode 100644 index 0000000..0302524 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/WatchPermissions.kt @@ -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 = 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 +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/WatchTransport.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/WatchTransport.kt new file mode 100644 index 0000000..1f8eeef --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/watch/WatchTransport.kt @@ -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 + + /** Запуск обмена с устройством по адресу (выбор — в «Настройках»). */ + 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") +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/HrPacketParserTest.kt b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/HrPacketParserTest.kt new file mode 100644 index 0000000..b7b2d7a --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/HrPacketParserTest.kt @@ -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) + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/HrSampleStoreTest.kt b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/HrSampleStoreTest.kt new file mode 100644 index 0000000..a8fef02 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/HrSampleStoreTest.kt @@ -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) + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/WatchChannelTest.kt b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/WatchChannelTest.kt new file mode 100644 index 0000000..e8e9846 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/WatchChannelTest.kt @@ -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(extraBufferCapacity = 64) + override val events: SharedFlow = flow + val starts = mutableListOf() + 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)) + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/WatchFormatTest.kt b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/WatchFormatTest.kt new file mode 100644 index 0000000..347c631 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/WatchFormatTest.kt @@ -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)) + } +}