m12-watch-channel (итерация 5): live-пульс с Fenix 6X — errata ОВ-3: companion-канал Connect IQ нереализуем на fenix 6X (CIQ ≤4.1.7, Communications в полях данных с API 5.0), транспорт = штатная трансляция «Broadcast Heart Rate» (BLE Heart Rate Service, односторонний, без GCM/ANT-железа) за интерфейсом WatchTransport — мультичасовая инфраструктура (указание владельца: любые часы со стандартной трансляцией, CIQ 5+/ANT+ — будущие реализации); таймер отдыха на часах автономный (Ф-2.4 errata, настройка поля, умолчание Rest.DefaultSec); ядро: HrBleClient (скан 0x180D→indications 0x2A37, оба поколения GATT-колбэков, батарея 0x180F опц., автопереподключение 1/5/30 с) + HrPacketParser + HrSampleStore + фасад WatchChannel (статусы/avgHr/freshHr/series, в БД не пишет) + WatchFormat + DeviceScanner; UI: статус-строка и блок пульса 44sp+зона+Canvas-линия последнего подхода на «Тренировке», finishWorkout(avgHr)→sync avg_hr, «Взять с часов» (≤30 с) на «Допуске», блок «Часы» (скан 15 с/выбор/забыть/инструкция) в «Настройках», 5 BLE-разрешений; unit 89/89 (m12 +26), APK 57,3 МБ; errata НД: requirements Ф-2.4/Ф-3.2/ОВ-реестр№3, tech-req 3п.3+7п.1, architecture 1.1/1.2/3/1.4; BLE на устройстве — приёмка владельца

This commit is contained in:
virus-home 2026-10-19 14:08:54 +05:00
parent 28445f28b9
commit d4867acefd
29 changed files with 2092 additions and 21 deletions

View File

@ -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 параллельно

View File

@ -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) |

View File

@ -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 |

View File

@ -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.

View File

@ -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/)

View File

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

View File

@ -0,0 +1,53 @@
# Спецификация 02: HrSampleStore — буфер пульса (чистый JVM)
Источники: Ф-3.2 (текущее значение + линия последнего подхода),
module_reqs разд. 1 (буфер, устаревание >10 с, решение 4 — только
память). Единица 2.
## 1. Интерфейс
```kotlin
class HrSampleStore {
fun add(bpm: Int, atMs: Long) // сэмпл транспортом
fun current(nowMs: Long, staleAfterMs: Long = 10_000): Int?
fun average(fromMs: Long, toMs: Long): Int? // среднее за окно
fun series(fromMs: Long, toMs: Long): List<Pair<Long, Int>> // (atMs, bpm)
fun clear() // сброс (новая тренировка)
val lastSampleAtMs: Long?
}
```
- время — абсолютные миллисекунды (`System.currentTimeMillis()`
передаётся вызывающим; в тестах — фикссированные значения);
- хранение: весь список сэмплов сессии (`ArrayList<Pair<Long, Int>>`)
— при 1 Гц и 3-часовой тренировке ~10⁴ записей, деградации
памяти нет; среднее считается инкрементально по окну проходом
(список короткий), без накопления суммы всей сессии;
- `current`: последний сэмпл, если `nowMs − atMs ≤ staleAfterMs`,
иначе `null` (прочерк, «нет данных»);
- `average`: арифметическое `Int` (округление вниз) по сэмплам
окна `[fromMs, toMs]`; пусто → `null`; `toMs` обычно «сейчас»;
- `series` — сэмплы окна в порядке поступления (линия последнего
подхода: `fromMs = время последнего logSet`, толщина линии —
один сэмпл = одна точка);
- значения bpm вне 30–250 в буфер не попадают (отфильтрованы
парсером, спец 01 §3 — двойная защита: `add` тоже игнорирует
вне диапазона).
## 2. Связь с тренировкой
- окно средней — от старта тренировки (`workout.startedAt`) до
финиша; сэмплы вне окна (канал работал до старта — экран
«Допуск») в среднее не входят;
- `clear()` вызывается фасадом при `unbind` после финишированной
тренировки; между тренировками буфер накапливается только при
открытом экране «Допуск» (для «взять с часов», спец 03 §4).
## 3. Тесты (pure JVM)
- фильтрация диапазона при `add`;
- `current`: свежий / устаревший (10 001 мс) / пустой буфер;
- `average`: пусто → null; одно значение; окно вырезает сэмплы
до/после; округление вниз (127,5 → 127);
- `series`: порядок и граница окна включительно;
- `clear` обнуляет всё.

View File

@ -0,0 +1,104 @@
# Спецификация 03: WatchChannel — фасад канала
Источники: module_reqs разд. 1 (фасад, жизненный цикл, решения 4/5),
Ф-3.1/3.2, Ф-13.2, инвариант 3 (канал не пишет в БД). Единицы 3, 8.
## 1. Состояние
```kotlin
enum class WatchStatus { NOT_SELECTED, NO_PERMISSION, BT_OFF,
SCANNING, CONNECTED, NO_SIGNAL }
data class WatchChannelState(
val status: WatchStatus,
val hr: Int?, // свежий сэмпл (≤10 с) или null
val battery: Int?, // последний Battery-событие или null
)
```
Маппинг статусов Ф-3.1 (цвет — только дублирование 🛡):
| WatchStatus | Цвет | Текст статус-строки |
|---|---|---|
| CONNECTED + hr | зелёный | «часы: связь есть (заряд N %)» |
| CONNECTED без свежего hr | серый | «часы: подключены, нет сигнала» |
| NO_SIGNAL (после потери) | красный | «часы: нет связи» |
| SCANNING / CONNECTING | серый | «часы: поиск…» |
| NOT_SELECTED | серый | «часы: не выбраны» |
| BT_OFF / NO_PERMISSION | серый | «Bluetooth выключен» / «нет разрешения» |
## 2. Класс
```kotlin
class WatchChannel(
private val transport: WatchTransport, // HrBleClient (прод)
private val store: HrSampleStore,
) {
val state: StateFlow<WatchChannelState>
fun bind(workoutStartedAtMs: Long?) // экран Тренировка (есть дата) / Допуск (null)
fun unbind()
fun avgHr(nowMs: Long): Int? // среднее окна тренировки
fun freshHr(nowMs: Long, maxAgeMs: Long = 30_000): Int? // допуск Ф-13.2
}
```
- `bind`: подписка на `transport.events`; сэмплы → `store.add`;
Battery → state; статусы → state (приоритет блокировок:
NO_PERMISSION/BT_OFF > NOT_SELECTED > события транспорта);
затем `transport.start(address)` если адрес задан и разрешения
есть; повторный `bind` — no-op;
- `NOT_SELECTED`: адрес пуст (`Watch.HrDeviceAddress` = "") —
транспорт не стартует, статус «не выбраны» (в зале чужие
датчики — авто-подключение к первому найденному запрещено,
решение 5 гейта 1.3);
- `unbind`: `transport.stop()`; буфер НЕ чистится (среднее нужно
до финишированной записи — `clear` по факту финиша из VM);
- `avgHr`: `store.average(workoutStartedAtMs, nowMs)` — VM
передаёт в `WorkoutCore.finishWorkout(id, avgHr)` (цепочка
m09: `workout.avg_hr` → sync `avg_hr`; контракт не меняется);
- `freshHr`: последний сэмпл ≤30 с — для кнопки «взять с часов»
на «Допуске»;
- **инвариант 3**: фасад ничего не пишет в LocalDb/sync; единственная
точка записи — существующий `finishWorkout(avgHr)` со стороны VM.
## 3. Разрешения и адаптер (статусы без транспорта)
Проверки на каждый `bind` (и на возврат в foreground):
- 12+: не выданы `BLUETOOTH_SCAN`+`BLUETOOTH_CONNECT` →
`NO_PERMISSION` (UI показывает кнопку запроса, спец 04);
- 8–11: нет `ACCESS_FINE_LOCATION` → `NO_PERMISSION`;
- адаптер выключен → `BT_OFF` (при включении — интент из
спец 01 §2 перезапускает скан).
## 4. Интеграция (точки m10/m09)
- VM «Тренировки»: `DisposableEffect { bind(workout?.startedAtMs);
onDispose { unbind() } }`; статус-строка/пульс/линия — state +
`store.series(lastSetAtMs, now)`; `finishWorkout` вызывается
с `avgHr(now)`; после финиша `store.clear()`;
- VM «Допуска»: `bind(null)` на экране; кнопка «взять с часов»
активна при `freshHr != null`; `unbind` на выходе;
- контейнер (WatchBodyApp): `watchChannel` — singleton;
`HrBleClient` инжектится как `WatchTransport` (подмена фейком
в тестах; будущие транспорты — той же инъекцией, инвариант 6).
## 5. Настройка
`Watch.HrDeviceAddress: String` (DataStore, умолчание `""`):
BLE-адрес выбранного устройства; запись из блока «Часы» в
«Настройках» (спец 04); сброс — запись `""`. Валидация адреса
не требуется (несуществующий адрес = вечный SCANNING, читается
статусом).
## 6. Тесты (pure JVM, фейк WatchTransport)
- переходы: start(SCANNING)→CONNECTED→Sample→state.hr;
- потеря: CONNECTED→LOST→SCANNING, state «нет связи» → «поиск…»;
- NOT_SELECTED при пустом адресе — transport.start не вызывается;
- NO_PERMISSION/BT_OFF перекрывают события транспорта;
- устаревание hr (>10 с) — hr=null при CONNECTED;
- avgHr по окну тренировки (сэмплы до старта исключены);
freshHr 30 с — граница;
- unbind вызывает transport.stop; clear после финиша — из VM
(ручной сценарий).

View File

@ -0,0 +1,107 @@
# Спецификация 04: UI — «Тренировка», «Допуск», «Настройки» (интеграция m10)
Источники: Ф-3.1–3.3, Ф-13.2, ui-ux разд. 2, module_reqs разд. 2,
решения 3/5/6. Единицы 4–7. Все тексты дублируют цвет (🛡).
## 1. Экран «Тренировка» (замена заглушек WorkoutScreen:380/258)
### Статусная строка (Ф-3.1)
- слева направо: «часы: <статус> (заряд N %)» · «пульс: <bpm|—>» ·
«сеть: онлайн/офлайн» (сеть — как в m10);
- тексты и цвета — по таблице спец 03 §1; зелёный/красный/серый
— только дублирование текстом;
- заряд опускается (с прочерком) при `battery == null` —
формулировка «часы: связь есть», без «заряд —» (решение 3).
### Блок пульса (Ф-3.2)
- текущее значение — крупно (≥40 sp) в существующей зоне статуса,
окраска по зонам Танака (`ZoneCalculator` m10, `Hr.MaxBpm`;
границы — те же, что на «Профиле»): зона 1 recovery <50 % синий,
зона 2 лёгкая 50–70 % зелёный, зона 3 средняя 70–85 % жёлтый,
зона 4 высокая >85 % красный — рядом с числом название зоны
словами, как на «Профиле» m10 («recovery»/«лёгкая»/«средняя»/
«высокая») 🛡;
- под значением — линия последнего подхода: Canvas по
`store.series(lastSetAtMs, now)`, обновление ~1 Гц, без сеток;
пусто/устарело — прочерк «—» вместо линии;
- `lastSetAtMs` — время последнего подхода текущей тренировки
(VM из `setsFlow`, при отсутствии подходов — старт тренировки);
- нет связи/не выбраны/нет разрешения — значение «—», блок
сворачивается в статус-строку; голос и ручной ввод не зависят
(Ф-1.1, Ф-3.2).
### Финиш
- «Завершить» → `finishWorkout(id, avgHr = channel.avgHr(now))`;
после записи `store.clear()`; при null среднего — как в m09
(`avg_hr` не отправляется).
## 2. Экран «Допуск» (Ф-13.2)
- под полем «Пульс сидя» — вторичная кнопка «Взять с часов»:
активна при `freshHr(now) != null` (свежий сэмпл ≤30 с),
нажатие подставляет значение в поле (пикером остаётся правка);
нет сэмпла — кнопка неактивна с подписью причины («часы не
связаны» / «нет свежего значения»);
- экран держит канал открытым (`bind(null)`), для допуска
достаточно пульса покоя — атлет включает трансляцию заранее
(инструкция в «Настройках»).
## 3. Экран «Настройки» — блок «Часы»
По образцу блока «Распознавание речи» (m11):
- строка статуса канала (текст из спец 03 §1);
- «Найти часы» — скан 15 с по UUID 0x180D (запрашивает разрешения
при необходимости), список найденных: имя + адрес, тап — выбор
(запись `Watch.HrDeviceAddress`, индикатор «выбрано»);
- «Забыть часы» — сброс адреса (`""` → NOT_SELECTED);
- инструкция включения трансляции — платформо-нейтральный текст:
«Включите на часах трансляцию пульса по Bluetooth» +
расширяемый справочник производителей (v1 — один пункт:
Garmin: «виджет пульса → удержание MENU → Heart Rate Options →
Broadcast During Activity = Вкл»); справочник — `map` в коде
UI (не БД), добавление производителя — правкой одного списка;
- никаких маркателей конкретных моделей в логике.
## 4. Разрешения (runtime)
- экраны «Тренировка»/«Допуск»/«Настройки»: при статусе
NO_PERMISSION — строка «Нет разрешения Bluetooth» + кнопка
«Разрешить» → системный диалог
(12+: `BLUETOOTH_SCAN`+`BLUETOOTH_CONNECT` вместе; 8–11:
`ACCESS_FINE_LOCATION` с пояснением «для поиска часов»);
«Больше не спрашивать» — кнопка ведёт в системные настройки
приложения (проверка после возврата);
- отказ не блокирует экраны (🛡 инвариант 2): статус-строка
показывает причину, остальное работает.
## 5. Манифест
```xml
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
```
(`BLUETOOTH_CONNECT` — без `neverForLocation`, только SCAN.)
## 6. Тесты (pure JVM)
- форматирование статус-строки: все WatchStatus × батарея
есть/нет × пульс есть/устарел (тексты из спец 03 §1, заряд
опускается при null);
- зона пульса → слово-зона и цветовой токен (границы 50/70/85 %
от Hr.MaxBpm по ZoneCalculator);
- кнопка «Взять с часов»: active/inactive по freshHr, подстановка
значения;
- логика «Больше не спрашивать» — на уровне VM-флага (системный
диалог — ручная приёмка);
- Compose-иерархия — без unit (ручной смок владельца, критерий 3).

View File

@ -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 в телах разделов,
версии поднимутся на ближайшем ревью).

View File

@ -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 приёмки на
стороне владельца; логика покрыта фейками и байтовыми фикстурами.

View File

@ -4,6 +4,18 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- голос m11: runtime-запрос из кнопки микрофона (spec 05 §6) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- часы m12: BLE-трансляция пульса (spec 04 §5) -->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<application
android:name="com.watchbody.phone.WatchBodyApp"
android:label="Феникс 6X"

View File

@ -26,6 +26,21 @@ class AppContainer(context: android.content.Context) {
}
val ttsFeedback: AndroidTtsFeedback by lazy { AndroidTtsFeedback(appContext) }
/** m12: канал часов — транспорт BLE за интерфейсом WatchTransport (мультичасовая инфраструктура). */
val watchChannel: com.watchbody.phone.watch.WatchChannel by lazy {
com.watchbody.phone.watch.WatchChannel(
transport = com.watchbody.phone.watch.HrBleClient(appContext),
store = com.watchbody.phone.watch.HrSampleStore(),
environment = com.watchbody.phone.watch.AndroidChannelEnvironment(appContext),
scope = kotlinx.coroutines.CoroutineScope(
kotlinx.coroutines.SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate,
),
)
}
val deviceScanner: com.watchbody.phone.watch.DeviceScanner by lazy {
com.watchbody.phone.watch.DeviceScanner(appContext)
}
/** Клиент по актуальным настройкам — создаётся на каждое обращение. */
suspend fun api(): com.watchbody.phone.sync.WatchBodyApi {
val s = settings.get()

View File

@ -24,6 +24,7 @@ data class AppSettings(
val safety: SafetySettings = SafetySettings(),
val hrStrengthMax: Int = 120, // предупреждение во время силовой (Ф-13.7, применяется в m12)
val voiceModelUrl: String = "https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip", // m11
val watchHrDeviceAddress: String = "", // m12: BLE-адрес часов; "" — не выбраны (spec 03 §5)
)
class SettingsRepository(private val context: Context) {
@ -40,6 +41,7 @@ class SettingsRepository(private val context: Context) {
val restingHrSitting = intPreferencesKey("safety.resting_hr_sitting")
val hrStrengthMax = intPreferencesKey("safety.hr_strength_max")
val voiceModelUrl = stringPreferencesKey("voice.model_url")
val watchHrDeviceAddress = stringPreferencesKey("watch.hr_device_address")
}
suspend fun get(): AppSettings = context.dataStore.data.first().toSettings()
@ -58,6 +60,7 @@ class SettingsRepository(private val context: Context) {
prefs[Keys.restingHrSitting] = settings.safety.restingHrSitting
prefs[Keys.hrStrengthMax] = settings.hrStrengthMax
prefs[Keys.voiceModelUrl] = settings.voiceModelUrl
prefs[Keys.watchHrDeviceAddress] = settings.watchHrDeviceAddress
}
}
@ -78,6 +81,7 @@ class SettingsRepository(private val context: Context) {
),
hrStrengthMax = this[Keys.hrStrengthMax] ?: defaults.hrStrengthMax,
voiceModelUrl = this[Keys.voiceModelUrl] ?: defaults.voiceModelUrl,
watchHrDeviceAddress = this[Keys.watchHrDeviceAddress] ?: "",
)
}
}

View File

@ -14,6 +14,9 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -44,6 +47,20 @@ class AdmissionViewModel(private val container: AppContainer) : ViewModel() {
var activeVersionId: String? = null
private set
// часы m12 (spec 04 §2): пульс покоя до активности — свежий сэмпл ≤30 с
val watchState: kotlinx.coroutines.flow.StateFlow<com.watchbody.phone.watch.WatchChannelState> =
container.watchChannel.state
fun bindWatch() {
viewModelScope.launch {
container.watchChannel.bind(null, container.settings.get().watchHrDeviceAddress)
}
}
fun unbindWatch() = container.watchChannel.unbind()
fun freshHr(): Int? = container.watchChannel.freshHr()
init {
viewModelScope.launch {
safety = container.settings.get().safety
@ -89,6 +106,11 @@ class AdmissionViewModel(private val container: AppContainer) : ViewModel() {
fun AdmissionScreen(container: AppContainer, onStarted: () -> Unit) {
val vm = containerVM(container) { AdmissionViewModel(it) }
LaunchedEffect(Unit) { vm.bindWatch() }
DisposableEffect(Unit) {
onDispose { vm.unbindWatch() }
}
var bpSys by remember { mutableStateOf<Int?>(null) }
var bpDias by remember { mutableStateOf<Int?>(null) }
var hrSitting by remember { mutableStateOf<Int?>(null) }
@ -115,6 +137,17 @@ fun AdmissionScreen(container: AppContainer, onStarted: () -> Unit) {
IntPicker("АД систола", bpSys, { bpSys = it }, min = 70, max = 250, step = 5, start = 120, suffix = " мм рт.ст.")
IntPicker("АД диастола", bpDias, { bpDias = it }, min = 40, max = 150, step = 5, start = 80, suffix = " мм рт.ст.")
IntPicker("Пульс сидя", hrSitting, { hrSitting = it }, min = 30, max = 180, start = 70, suffix = " уд/мин")
val ws by vm.watchState.collectAsState()
val fresh = vm.freshHr()
OutlinedButton(
onClick = { fresh?.let { hrSitting = it } },
enabled = fresh != null,
modifier = Modifier.fillMaxWidth(),
) { Text("Взять с часов") }
Text(
com.watchbody.phone.watch.WatchFormat.admissionHint(ws.status == com.watchbody.phone.watch.WatchStatus.CONNECTED, fresh),
style = MaterialTheme.typography.bodySmall,
)
Button(
modifier = Modifier.fillMaxWidth(),
onClick = {

View File

@ -1,5 +1,6 @@
package com.watchbody.phone.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -98,6 +99,30 @@ class SettingsViewModel(private val container: AppContainer) : ViewModel() {
SyncWorker.scheduleNow(container.appContext)
}
// m12: часы (spec 04 §3) — скан/выбор/сброс устройства
var watchScanState by mutableStateOf<String?>(null)
private set
var watchScanResults by mutableStateOf<List<Pair<String, String>>>(emptyList())
private set
fun scanWatches() {
watchScanState = "Поиск 15 с…"
container.deviceScanner.scan { results ->
watchScanResults = results
watchScanState = when {
results.isEmpty() -> "Ничего не найдено — включите трансляцию пульса на часах"
else -> "Найдено: ${results.size}"
}
}
}
fun saveWatchAddress(address: String) {
viewModelScope.launch {
val s = container.settings.get()
container.settings.save(s.copy(watchHrDeviceAddress = address))
}
}
val queueCount = container.db.syncDao().countFlow()
}
@ -120,6 +145,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
var restingHrSitting by remember { mutableStateOf(90) }
var hrStrengthMax by remember { mutableStateOf(120) }
var voiceModelUrl by remember { mutableStateOf("https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip") }
var watchAddress by remember { mutableStateOf("") }
LaunchedEffect(vm.loaded) { // одна загрузка в форму
vm.loaded?.let { s ->
@ -131,6 +157,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
restingHrSitting = s.safety.restingHrSitting
hrStrengthMax = s.hrStrengthMax
voiceModelUrl = s.voiceModelUrl
watchAddress = s.watchHrDeviceAddress
}
}
@ -142,6 +169,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
),
hrStrengthMax = hrStrengthMax,
voiceModelUrl = voiceModelUrl,
watchHrDeviceAddress = watchAddress,
)
val validation = SettingsValidator.validate(draft)
@ -232,6 +260,53 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
}
vm.ttsState?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
Text("Часы (пульс по Bluetooth)", style = MaterialTheme.typography.titleMedium)
Text(
if (watchAddress.isBlank()) "Часы не выбраны — пульс на экране тренировки покажется прочерком"
else "Выбраны: $watchAddress",
style = MaterialTheme.typography.bodyMedium,
)
val context = androidx.compose.ui.platform.LocalContext.current
val btPermission = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions(),
) { }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(
onClick = {
if (com.watchbody.phone.watch.WatchPermissions.granted(context)) {
vm.scanWatches()
} else {
btPermission.launch(com.watchbody.phone.watch.WatchPermissions.required())
}
},
) { Text("Найти часы") }
if (watchAddress.isNotBlank()) {
OutlinedButton(onClick = { watchAddress = ""; vm.saveWatchAddress("") }) {
Text("Забыть")
}
}
}
vm.watchScanState?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
vm.watchScanResults.forEach { (name, addr) ->
Text(
"• $name ($addr)" + if (addr == watchAddress) " — выбрано" else "",
style = MaterialTheme.typography.bodyMedium,
color = if (addr == watchAddress) MaterialTheme.colorScheme.primary else androidx.compose.ui.graphics.Color.Unspecified,
modifier = Modifier
.fillMaxWidth()
.clickable {
watchAddress = addr
vm.saveWatchAddress(addr)
}
.padding(vertical = 2.dp),
)
}
Text(
"Включите на часах трансляцию пульса по Bluetooth. " +
"Garmin: виджет пульса → удержание MENU → Heart Rate Options → Broadcast During Activity = Вкл.",
style = MaterialTheme.typography.bodySmall,
)
if (!standalone) {
Text(
"Очередь отправки: $queue записей",

View File

@ -1,5 +1,6 @@
package com.watchbody.phone.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -7,6 +8,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -39,6 +41,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
@ -49,14 +52,23 @@ import com.watchbody.phone.core.ProgramView
import com.watchbody.phone.core.RestPhase
import com.watchbody.phone.core.RestTimer
import com.watchbody.phone.core.SetPlan
import com.watchbody.phone.core.ZoneCalculator
import com.watchbody.phone.db.Exercise
import com.watchbody.phone.db.Workout
import com.watchbody.phone.db.WorkoutSet
import com.watchbody.phone.sync.SyncWorker
import com.watchbody.phone.voice.VoiceEffect
import com.watchbody.phone.voice.humanKg
import com.watchbody.phone.watch.HrZoneColor
import com.watchbody.phone.watch.StatusColor
import com.watchbody.phone.watch.WatchChannelState
import com.watchbody.phone.watch.WatchFormat
import com.watchbody.phone.watch.WatchPermissions
import com.watchbody.phone.watch.WatchStatus
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
@ -105,6 +117,66 @@ class WorkoutViewModel(private val container: AppContainer) : ViewModel() {
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), WorkoutUiState())
// ---------- часы m12 (spec 03 §4, 04 §1) ----------
val watchState: StateFlow<WatchChannelState> = container.watchChannel.state
/** Линия последнего подхода (Ф-3.2) — от последнего logSet до сейчас. */
var hrSeries by mutableStateOf<List<Pair<Long, Int>>>(emptyList())
private set
var hrZones by mutableStateOf<ZoneCalculator.Zones?>(null)
private set
private var lastSetAtMs: Long? = null
private var seriesJob: Job? = null
init {
viewModelScope.launch { watchState.collect { refreshSeries() } }
viewModelScope.launch {
state.collect { st ->
val from = st.workout?.let { w -> st.sets.lastOrNull()?.doneAt ?: w.startedAt }
if (from != lastSetAtMs) {
lastSetAtMs = from
refreshSeries()
}
}
}
viewModelScope.launch {
val s = container.settings.get()
val p = container.db.healthDao().profile()
ZoneCalculator.maxHr(p?.birthYear, p?.hrMax ?: s.hrMaxBpm, ProfileViewModel.currentYear())
?.let { hrZones = ZoneCalculator.zones(it) }
}
}
/** Открытие/возврат на экран или смена адреса — канал активен при открытом экране. */
fun rebindWatch() {
viewModelScope.launch {
val st = state.value
lastSetAtMs = st.workout?.let { w -> st.sets.lastOrNull()?.doneAt ?: w.startedAt }
container.watchChannel.bind(st.workout?.startedAt, container.settings.get().watchHrDeviceAddress)
refreshSeries()
seriesJob?.cancel()
seriesJob = viewModelScope.launch {
while (true) {
delay(2_000)
refreshSeries()
}
}
}
}
fun unbindWatch() {
seriesJob?.cancel()
seriesJob = null
container.watchChannel.unbind()
}
private fun refreshSeries() {
val from = lastSetAtMs ?: return
hrSeries = container.watchChannel.series(from)
}
// ---------- голос m11 (spec 05 §1–3) ----------
var listening by mutableStateOf(false)
@ -255,7 +327,8 @@ class WorkoutViewModel(private val container: AppContainer) : ViewModel() {
fun finish(workoutId: String, onDone: () -> Unit) {
viewModelScope.launch {
container.core.finishWorkout(workoutId, avgHr = null) // пульс — m12
container.core.finishWorkout(workoutId, avgHr = container.watchChannel.avgHr()) // m12: среднее канала
container.watchChannel.clearSamples()
SyncWorker.schedule(container.appContext, container.settings.get().wifiOnly)
onDone()
}
@ -325,6 +398,16 @@ fun WorkoutScreen(container: AppContainer) {
onDispose { vm.stopVoice() } // выход с экрана останавливает прослушивание (Ф-1.1)
}
// часы m12 (spec 04 §1/§4): канал активен при открытом экране
val ws by vm.watchState.collectAsState()
val btPermission = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) { vm.rebindWatch() }
LaunchedEffect(state.workout?.id) { vm.rebindWatch() }
DisposableEffect(Unit) {
onDispose { vm.unbindWatch() }
}
Scaffold(
topBar = {
TopAppBar(
@ -377,19 +460,40 @@ fun WorkoutScreen(container: AppContainer) {
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
// статус-строка (Ф-3.1/3.2): часы/пульс — прочерки до m12
// статус-строка (Ф-3.1): часы/пульс — m12; цвет дублируется текстом 🛡
Text(
"часы: нет связи пульс: — сеть: ${
"часы: ${WatchFormat.watchStatusText(ws)} пульс: ${ws.hr ?: "—"} сеть: ${
if (state.offline) "офлайн" else "онлайн"
}",
style = MaterialTheme.typography.bodySmall,
color = when (WatchFormat.statusColor(ws)) {
StatusColor.GREEN -> Color(0xFF81C784)
StatusColor.RED -> Color(0xFFE57373)
StatusColor.GRAY -> MaterialTheme.colorScheme.onSurfaceVariant
},
)
if (ws.status == WatchStatus.NO_PERMISSION) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Text("Нет разрешения Bluetooth", style = MaterialTheme.typography.bodySmall)
TextButton(onClick = { btPermission.launch(WatchPermissions.required()) }) {
Text("Разрешить")
}
}
}
Text(
"вид: ${kindLabel(w.sessionKind)}" + (w.programVersionId?.let { "" } ?: " (вне программы)"),
style = MaterialTheme.typography.bodySmall,
)
}
if (ws.hr != null) {
item { HrBlock(ws.hr!!, vm.hrZones, vm.hrSeries) }
}
item {
// голос: индикатор + последняя команда — текст, не только цвет 🛡
Text(
@ -509,6 +613,45 @@ fun WorkoutScreen(container: AppContainer) {
}
}
/** Пульс (Ф-3.2): крупное значение ≥40 sp, зона словами (🛡 как на «Профиле»), линия последнего подхода. */
@Composable
private fun HrBlock(bpm: Int, zones: ZoneCalculator.Zones?, series: List<Pair<Long, Int>>) {
val zone = zones?.let { WatchFormat.hrZone(bpm, it) }
val color = when (zone?.color) {
null -> MaterialTheme.colorScheme.onSurface
HrZoneColor.BLUE -> Color(0xFF64B5F6)
HrZoneColor.GREEN -> Color(0xFF81C784)
HrZoneColor.YELLOW -> Color(0xFFFFD54F)
HrZoneColor.RED -> Color(0xFFE57373)
}
Row(verticalAlignment = androidx.compose.ui.Alignment.Bottom) {
Text(
"$bpm",
fontSize = 44.sp, // ≥40 sp, spec 04 §1
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
color = color,
)
Column(Modifier.padding(start = 8.dp, bottom = 6.dp)) {
Text("уд/мин", style = MaterialTheme.typography.bodySmall)
zone?.let { Text(it.word, style = MaterialTheme.typography.bodyMedium) }
}
}
Canvas(Modifier.fillMaxWidth().height(36.dp)) {
if (series.size < 2) return@Canvas
val minB = series.minOf { it.second } - 5
val maxB = series.maxOf { it.second } + 5
val t0 = series.first().first
val dt = (series.last().first - t0).coerceAtLeast(1)
val path = androidx.compose.ui.graphics.Path()
series.forEachIndexed { i, (t, v) ->
val x = size.width * ((t - t0).toFloat() / dt)
val y = size.height * (1f - (v - minB).toFloat() / (maxB - minB))
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
}
drawPath(path, color, style = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx()))
}
}
@Composable
private fun RestCard(restLeft: Int, onStart: () -> Unit) {
val phase = if (restLeft >= 0) RestTimer.phase(restLeft) else null

View File

@ -0,0 +1,52 @@
package com.watchbody.phone.watch
import android.annotation.SuppressLint
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
/**
* Скан пульс-датчиков для блока «Часы» в «Настройках» (m12 spec 04 §3):
* 15 с по UUID 0x180D, список «имя → адрес». Выбор устройства вручную —
* авто-подключение к первому найденному запрещено (чужие HRM в зале).
*/
@Suppress("MissingPermission") // вызывается только при выданных разрешениях (UI)
class DeviceScanner(private val context: Context) {
/** [onDone] получает список (имя, адрес) по завершении окна скана. */
fun scan(durationMs: Long = 15_000, onDone: (List<Pair<String, String>>) -> Unit) {
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
if (adapter == null || !adapter.isEnabled) {
onDone(emptyList())
return
}
val found = LinkedHashMap<String, String>() // адрес → имя
val handler = Handler(Looper.getMainLooper())
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
val name = result.device.name ?: result.scanRecord?.deviceName ?: "без имени"
found[result.device.address] = name
}
}
try {
adapter.bluetoothLeScanner.startScan(
listOf(ScanFilter.Builder().setServiceUuid(ParcelUuid(BleProfiles.HEART_RATE_SERVICE)).build()),
ScanSettings.Builder().build(),
callback,
)
} catch (_: SecurityException) {
onDone(emptyList())
return
}
handler.postDelayed({
runCatching { adapter.bluetoothLeScanner.stopScan(callback) }
onDone(found.map { (address, name) -> name to address })
}, durationMs)
}
}

View File

@ -0,0 +1,267 @@
package com.watchbody.phone.watch
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
/**
* BLE-реализация транспорта: стандартный Heart Rate Service 0x180D
* (штатная трансляция «Broadcast Heart Rate», spec 01 §2). Никаких
* платформо-специфичных UUID/имён (инвариант 6) — любые часы со
* стандартной трансляцией. Разрешения проверяет фасад (WatchPermissions).
*/
@Suppress("MissingPermission") // вызывается только при выданных разрешениях (фасад/UI)
class HrBleClient(private val context: Context) : WatchTransport {
private val _events = MutableSharedFlow<TransportEvent>(
extraBufferCapacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
override val events: SharedFlow<TransportEvent> = _events
private val handler = Handler(Looper.getMainLooper())
private val adapter: BluetoothAdapter?
get() = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
private var started = false
private var targetAddress: String? = null
private var gatt: BluetoothGatt? = null
private var scanning = false
private var reconnectAttempt = 0
private var reconnectRunnable: Runnable? = null
private var receiverRegistered = false
private val btReceiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
if (intent?.action != BluetoothAdapter.ACTION_STATE_CHANGED) return
when (intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, -1)) {
BluetoothAdapter.STATE_OFF -> {
closeGatt()
cancelReconnect()
if (started) emit(TransportEvent.Status(TransportStatus.BT_OFF))
}
BluetoothAdapter.STATE_ON -> if (started) startScan()
}
}
}
private val scanCallback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (result.device.address == targetAddress) {
stopScan()
connect(result.device)
}
}
}
override fun start(deviceAddress: String) {
handler.post {
if (started && targetAddress == deviceAddress) return@post
started = true
targetAddress = deviceAddress
reconnectAttempt = 0
registerReceiverOnce()
val ad = adapter
if (ad == null || !ad.isEnabled) {
emit(TransportEvent.Status(TransportStatus.BT_OFF))
return@post
}
startScan()
}
}
override fun stop() {
handler.post {
started = false
targetAddress = null
cancelReconnect()
stopScan()
closeGatt()
if (receiverRegistered) {
runCatching { context.unregisterReceiver(btReceiver) }
receiverRegistered = false
}
emit(TransportEvent.Status(TransportStatus.IDLE))
}
}
private fun startScan() {
val scanner = adapter?.takeIf { it.isEnabled }?.bluetoothLeScanner ?: return
if (scanning) return
emit(TransportEvent.Status(TransportStatus.SCANNING))
val filters = listOf(
ScanFilter.Builder().setServiceUuid(ParcelUuid(BleProfiles.HEART_RATE_SERVICE)).build(),
)
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
try {
scanner.startScan(filters, settings, scanCallback)
scanning = true
} catch (_: SecurityException) {
// разрешения отозвали между проверкой и стартом — статус покажет фасад
}
}
private fun stopScan() {
if (!scanning) return
runCatching { adapter?.bluetoothLeScanner?.stopScan(scanCallback) }
scanning = false
}
private fun connect(device: BluetoothDevice) {
emit(TransportEvent.Status(TransportStatus.CONNECTING))
closeGatt()
gatt = try {
device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
} catch (_: SecurityException) {
null
}
}
private val gattCallback = object : BluetoothGattCallback() {
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
when (newState) {
BluetoothProfile.STATE_CONNECTED -> g.discoverServices()
BluetoothProfile.STATE_DISCONNECTED -> {
closeGatt()
if (started) {
emit(TransportEvent.Status(TransportStatus.LOST))
scheduleReconnect()
}
}
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
scheduleReconnect()
return
}
val hrChar = g.getService(BleProfiles.HEART_RATE_SERVICE)
?.getCharacteristic(BleProfiles.HEART_RATE_MEASUREMENT)
if (hrChar == null) {
// устройство не отдаёт HR-сервис — переподключение не поможет
closeGatt()
if (started) emit(TransportEvent.Status(TransportStatus.LOST))
return
}
setDescriptor(g, hrChar, indication = true)
emit(TransportEvent.Status(TransportStatus.CONNECTED))
reconnectAttempt = 0
// батарея опциональна (Ф-3.1 «если передаётся»)
g.getService(BleProfiles.BATTERY_SERVICE)?.getCharacteristic(BleProfiles.BATTERY_LEVEL)
?.let { battery ->
setDescriptor(g, battery, indication = false)
@Suppress("DEPRECATION") // новая сигнатура — только 33+, старая работает везде
runCatching { g.readCharacteristic(battery) }
}
}
@Deprecated("Deprecated in Java")
@Suppress("DEPRECATION")
override fun onCharacteristicRead(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
if (characteristic.uuid == BleProfiles.BATTERY_LEVEL && status == BluetoothGatt.GATT_SUCCESS) {
characteristic.value?.firstOrNull()?.let { emit(TransportEvent.Battery(it.toInt() and 0xFF)) }
}
}
override fun onCharacteristicRead(
g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray, status: Int,
) {
if (characteristic.uuid == BleProfiles.BATTERY_LEVEL && status == BluetoothGatt.GATT_SUCCESS) {
value.firstOrNull()?.let { emit(TransportEvent.Battery(it.toInt() and 0xFF)) }
}
}
@Deprecated("Deprecated in Java")
@Suppress("DEPRECATION")
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
if (characteristic.uuid == BleProfiles.HEART_RATE_MEASUREMENT) {
characteristic.value?.let { onMeasurement(it) }
}
}
override fun onCharacteristicChanged(
g: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray,
) {
if (characteristic.uuid == BleProfiles.HEART_RATE_MEASUREMENT) onMeasurement(value)
}
}
@Suppress("DEPRECATION")
private fun setDescriptor(g: BluetoothGatt, char: BluetoothGattCharacteristic, indication: Boolean) {
val ccc = char.getDescriptor(BleProfiles.CLIENT_CONFIG) ?: return
val value = if (indication) {
BluetoothGattDescriptor.ENABLE_INDICATION_VALUE
} else {
BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
}
if (Build.VERSION.SDK_INT >= 33) g.writeDescriptor(ccc, value)
else {
ccc.value = value
g.writeDescriptor(ccc)
}
}
private fun onMeasurement(bytes: ByteArray) {
HrPacketParser.parse(bytes)?.let {
emit(TransportEvent.Sample(it.bpm, System.currentTimeMillis()))
}
}
private fun scheduleReconnect() {
if (!started) return
cancelReconnect()
reconnectAttempt += 1
val runnable = Runnable { if (started) startScan() }
reconnectRunnable = runnable
handler.postDelayed(runnable, ReconnectPolicy.nextDelayMs(reconnectAttempt))
}
private fun cancelReconnect() {
reconnectRunnable?.let { handler.removeCallbacks(it) }
reconnectRunnable = null
}
private fun closeGatt() {
runCatching {
gatt?.disconnect()
gatt?.close()
}
gatt = null
}
private fun registerReceiverOnce() {
if (receiverRegistered) return
val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
runCatching { context.registerReceiver(btReceiver, filter) }
receiverRegistered = true
}
private fun emit(event: TransportEvent) {
_events.tryEmit(event)
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,35 @@
package com.watchbody.phone.watch
/**
* Буфер пульса в памяти (m12 spec 02) — pure JVM, в БД не пишется
* (инвариант 3: история пульса — из .fit, m07/m08). Время —
* абсолютные миллисекунды.
*/
class HrSampleStore {
private val samples = ArrayList<Pair<Long, Int>>() // (atMs, bpm)
val lastSampleAtMs: Long? get() = samples.lastOrNull()?.first
/** Диапазон 30–250 — двойная защита (первая — парсер, spec 01 §3). */
fun add(bpm: Int, atMs: Long) {
if (bpm in 30..250) samples.add(atMs to bpm)
}
/** Последний сэмпл не старше [staleAfterMs]; иначе null («нет данных»). */
fun current(nowMs: Long, staleAfterMs: Long = 10_000): Int? =
samples.lastOrNull { nowMs - it.first in 0..staleAfterMs }?.second
/** Среднее (Int, вниз) по окну [fromMs, toMs]; пусто → null. */
fun average(fromMs: Long, toMs: Long): Int? {
val win = samples.filter { it.first in fromMs..toMs }
if (win.isEmpty()) return null
return win.sumOf { it.second } / win.size
}
/** Сэмплы окна в порядке поступления — линия последнего подхода (Ф-3.2). */
fun series(fromMs: Long, toMs: Long): List<Pair<Long, Int>> =
samples.filter { it.first in fromMs..toMs }
fun clear() = samples.clear()
}

View File

@ -0,0 +1,145 @@
package com.watchbody.phone.watch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* Фасад канала часов (m12 spec 03). Инвариант 3: канал ничего не пишет
* в LocalDb/sync — единственная точка записи `avg_hr` через
* `WorkoutCore.finishWorkout(avgHr)` со стороны VM.
*/
enum class WatchStatus { NOT_SELECTED, NO_PERMISSION, BT_OFF, SCANNING, CONNECTED, NO_SIGNAL }
data class WatchChannelState(
val status: WatchStatus,
val hr: Int? = null, // свежий сэмпл (≤10 с) или null
val battery: Int? = null, // последний Battery-событие или null (Ф-3.1 «если передаётся»)
)
/** Проверки окружения; Android-реализация — WatchPermissions (m12 spec 03 §3). */
interface ChannelEnvironment {
fun hasBluetoothPermissions(): Boolean
fun isBluetoothEnabled(): Boolean
}
class WatchChannel(
private val transport: WatchTransport,
private val store: HrSampleStore,
private val environment: ChannelEnvironment,
private val scope: CoroutineScope,
private val now: () -> Long = System::currentTimeMillis,
private val staleAfterMs: Long = 10_000,
) {
private val _state = MutableStateFlow(WatchChannelState(WatchStatus.NOT_SELECTED))
val state: StateFlow<WatchChannelState> = _state
private var workoutStartedAtMs: Long? = null
private var address: String = ""
private var bound = false
private var ticker: Job? = null
private var collectJob: Job? = null
init {
collectJob = scope.launch { transport.events.collect { onEvent(it) } }
}
/**
* Открытие экрана «Тренировка» (дата старта) или «Допуск» (null).
* Идемпотентен; повторный вызов (смена адреса/возврат разрешения)
* переоценивает статус.
*/
fun bind(workoutStartedAtMs: Long?, deviceAddress: String) {
this.workoutStartedAtMs = workoutStartedAtMs
this.address = deviceAddress
bound = true
applyPreconditions()
startTicker()
}
fun unbind() {
bound = false
ticker?.cancel()
ticker = null
transport.stop()
_state.value = WatchChannelState(WatchStatus.NOT_SELECTED)
}
/** Полная остановка канала (тесты/закрытие приложения): коллектор + тикер + транспорт. */
fun dispose() {
unbind()
collectJob?.cancel()
collectJob = null
}
/** NO_PERMISSION/NOT_SELECTED — без запуска транспорта (🛡 не блок). */
private fun applyPreconditions() {
val blocked = when {
!environment.hasBluetoothPermissions() -> WatchStatus.NO_PERMISSION
address.isBlank() -> WatchStatus.NOT_SELECTED
else -> null
}
if (blocked != null) {
transport.stop()
_state.value = WatchChannelState(blocked)
return
}
// при выключенном адаптере клиент эмитит BT_OFF и держит приёмник
// состояния Bluetooth — включение перезапустит скан (spec 01 §2)
transport.start(address)
}
private fun onEvent(event: TransportEvent) {
when (event) {
is TransportEvent.Sample -> {
store.add(event.bpm, event.atMs)
val hr = if (_state.value.status == WatchStatus.CONNECTED) store.current(now(), staleAfterMs) else null
_state.value = _state.value.copy(hr = hr)
}
is TransportEvent.Battery ->
_state.value = _state.value.copy(battery = event.percent)
is TransportEvent.Status -> if (bound) {
val mapped = when (event.status) {
TransportStatus.SCANNING, TransportStatus.CONNECTING -> WatchStatus.SCANNING
TransportStatus.CONNECTED -> WatchStatus.CONNECTED
TransportStatus.LOST -> WatchStatus.NO_SIGNAL
TransportStatus.BT_OFF -> WatchStatus.BT_OFF
TransportStatus.IDLE -> return // stop() — экран уже ушёл
}
val hr = if (mapped == WatchStatus.CONNECTED) store.current(now(), staleAfterMs) else null
_state.value = _state.value.copy(status = mapped, hr = hr)
}
}
}
/** Устаревание пульса (>10 с тишины при CONNECTED) без новых сэмплов. */
private fun startTicker() {
ticker?.cancel()
ticker = scope.launch {
while (true) {
delay(2_000)
if (_state.value.status == WatchStatus.CONNECTED) {
val hr = store.current(now(), staleAfterMs)
if (_state.value.hr != hr) _state.value = _state.value.copy(hr = hr)
}
}
}
}
/** Среднее за окно тренировки — в finishWorkout(avgHr) (цепочка m09). */
fun avgHr(): Int? = store.average(workoutStartedAtMs ?: 0, now())
/** Свежий сэмпл для допуска (Ф-13.2, ≤30 с). */
fun freshHr(maxAgeMs: Long = 30_000): Int? = store.current(now(), maxAgeMs)
/** Линия последнего подхода (Ф-3.2) — от последнего logSet до сейчас. */
fun series(fromMs: Long): List<Pair<Long, Int>> = store.series(fromMs, now())
/** Сброс буфера — VM после финишированной тренировки (spec 03 §2). */
fun clearSamples() = store.clear()
}

View File

@ -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 -> "часы не связаны"
}
}

View File

@ -0,0 +1,36 @@
package com.watchbody.phone.watch
import android.Manifest
import android.bluetooth.BluetoothManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
/**
* Разрешения и состояние адаптера (m12 spec 01 §6, 03 §3).
* 12+ — BLUETOOTH_SCAN (neverForLocation) + BLUETOOTH_CONNECT;
* 8–11 — ACCESS_FINE_LOCATION. Отказ не блокирует экраны (🛡 инвариант 2).
*/
object WatchPermissions {
fun required(): Array<String> = if (Build.VERSION.SDK_INT >= 31) {
arrayOf(Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT)
} else {
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
}
fun granted(context: Context): Boolean = required().all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
/** Android-реализация окружения фасада (m12 spec 03 §3). */
class AndroidChannelEnvironment(private val context: Context) : ChannelEnvironment {
override fun hasBluetoothPermissions(): Boolean = WatchPermissions.granted(context)
override fun isBluetoothEnabled(): Boolean =
(context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)
?.adapter?.isEnabled == true
}

View File

@ -0,0 +1,49 @@
package com.watchbody.phone.watch
import kotlinx.coroutines.flow.SharedFlow
import java.util.UUID
/**
* Платформо-независимый транспорт канала часов (m12 spec 01 §1,
* инвариант 6 — мультичасовая инфраструктура): BLE-реализация первая,
* CIQ 5+ / ANT+ подключаются реализациями интерфейса без правок
* фасада и UI.
*/
enum class TransportStatus {
IDLE, SCANNING, CONNECTING, CONNECTED, LOST, BT_OFF,
}
sealed interface TransportEvent {
data class Status(val status: TransportStatus) : TransportEvent
data class Sample(val bpm: Int, val atMs: Long) : TransportEvent
data class Battery(val percent: Int) : TransportEvent
}
interface WatchTransport {
/** События статуса/пульса/батареи; Sample — только в CONNECTED. */
val events: SharedFlow<TransportEvent>
/** Запуск обмена с устройством по адресу (выбор — в «Настройках»). */
fun start(deviceAddress: String)
/** Полная остановка (скан, GATT, приёмник); повторный start — с SCANNING. */
fun stop()
}
/** Паузы автопереподключения: 1 c → 5 c → 30 c (далее каждые 30 c), m12 spec 01 §4. */
object ReconnectPolicy {
fun nextDelayMs(attempt: Int): Long = when (attempt) {
1 -> 1_000
2 -> 5_000
else -> 30_000
}
}
/** Стандартные профили GATT — только они, без платформо-специфики (инвариант 6). */
object BleProfiles {
val HEART_RATE_SERVICE: UUID = UUID.fromString("0000180d-0000-1000-8000-00805f9b34fb")
val HEART_RATE_MEASUREMENT: UUID = UUID.fromString("00002a37-0000-1000-8000-00805f9b34fb")
val BATTERY_SERVICE: UUID = UUID.fromString("0000180f-0000-1000-8000-00805f9b34fb")
val BATTERY_LEVEL: UUID = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb")
val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,190 @@
package com.watchbody.phone
import com.watchbody.phone.watch.ChannelEnvironment
import com.watchbody.phone.watch.HrSampleStore
import com.watchbody.phone.watch.ReconnectPolicy
import com.watchbody.phone.watch.TransportEvent
import com.watchbody.phone.watch.TransportStatus
import com.watchbody.phone.watch.WatchChannel
import com.watchbody.phone.watch.WatchStatus
import com.watchbody.phone.watch.WatchTransport
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Фасад канала часов на фейке транспорта (m12 spec 03 §6) + ReconnectPolicy. */
class WatchChannelTest {
private class FakeTransport : WatchTransport {
val flow = MutableSharedFlow<TransportEvent>(extraBufferCapacity = 64)
override val events: SharedFlow<TransportEvent> = flow
val starts = mutableListOf<String>()
var stops = 0
override fun start(deviceAddress: String) {
starts += deviceAddress
}
override fun stop() {
stops += 1
}
}
private class FakeEnv(
var permissions: Boolean = true,
var btEnabled: Boolean = true,
) : ChannelEnvironment {
override fun hasBluetoothPermissions() = permissions
override fun isBluetoothEnabled() = btEnabled
}
@Test
fun `пустой адрес - NOT_SELECTED без старта транспорта`() = runTest {
val t = FakeTransport()
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { 0 })
runCurrent()
ch.bind(workoutStartedAtMs = 1_000, deviceAddress = "")
runCurrent()
assertEquals(WatchStatus.NOT_SELECTED, ch.state.value.status)
assertEquals(0, t.starts.size)
ch.dispose()
}
@Test
fun `нет разрешения - NO_PERMISSION без старта`() = runTest {
val t = FakeTransport()
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(permissions = false), this, now = { 0 })
runCurrent()
ch.bind(null, "AA:BB")
runCurrent()
assertEquals(WatchStatus.NO_PERMISSION, ch.state.value.status)
assertEquals(0, t.starts.size)
ch.dispose()
}
@Test
fun `скан - связь - сэмпл - потеря`() = runTest {
var nowMs = 0L
val t = FakeTransport()
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { nowMs })
runCurrent()
ch.bind(workoutStartedAtMs = 1_000, deviceAddress = "AA:BB")
runCurrent()
assertEquals(listOf("AA:BB"), t.starts)
t.flow.tryEmit(TransportEvent.Status(TransportStatus.SCANNING))
runCurrent()
assertEquals(WatchStatus.SCANNING, ch.state.value.status)
t.flow.tryEmit(TransportEvent.Status(TransportStatus.CONNECTED))
runCurrent()
assertEquals(WatchStatus.CONNECTED, ch.state.value.status)
nowMs = 1_500
t.flow.tryEmit(TransportEvent.Sample(120, nowMs))
runCurrent()
assertEquals(120, ch.state.value.hr)
t.flow.tryEmit(TransportEvent.Battery(80))
runCurrent()
assertEquals(80, ch.state.value.battery)
t.flow.tryEmit(TransportEvent.Status(TransportStatus.LOST))
runCurrent()
assertEquals(WatchStatus.NO_SIGNAL, ch.state.value.status)
assertNull(ch.state.value.hr) // нет связи — пульс-прочерк
ch.dispose()
}
@Test
fun `среднее за окно тренировки - до старта не входит`() = runTest {
var nowMs = 3_000L
val t = FakeTransport()
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { nowMs })
runCurrent()
ch.bind(workoutStartedAtMs = 1_000, deviceAddress = "AA:BB")
runCurrent()
t.flow.tryEmit(TransportEvent.Status(TransportStatus.CONNECTED))
t.flow.tryEmit(TransportEvent.Sample(200, 500)) // до тренировки
t.flow.tryEmit(TransportEvent.Sample(100, 1_500)) // в окне
t.flow.tryEmit(TransportEvent.Sample(110, 2_500)) // в окне
runCurrent()
assertEquals(105, ch.avgHr())
ch.dispose()
}
@Test
fun `устаревание пульса - тикер 2 с`() = runTest {
var nowMs = 0L
val t = FakeTransport()
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { nowMs })
runCurrent()
ch.bind(0, "AA:BB")
t.flow.tryEmit(TransportEvent.Status(TransportStatus.CONNECTED))
t.flow.tryEmit(TransportEvent.Sample(120, 0))
runCurrent()
assertEquals(120, ch.state.value.hr)
nowMs = 10_001 // тишина > 10 с
advanceTimeBy(2_000)
runCurrent() // задача ровно на границе окна выполняется на текущем такте
assertNull(ch.state.value.hr)
assertEquals(WatchStatus.CONNECTED, ch.state.value.status)
ch.dispose()
}
@Test
fun `freshHr 30 с для допуска`() = runTest {
var nowMs = 0L
val t = FakeTransport()
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { nowMs })
runCurrent()
ch.bind(null, "AA:BB")
t.flow.tryEmit(TransportEvent.Status(TransportStatus.CONNECTED))
t.flow.tryEmit(TransportEvent.Sample(72, 0))
runCurrent()
assertEquals(72, ch.freshHr())
nowMs = 30_000
assertEquals(72, ch.freshHr()) // ровно 30 с
nowMs = 30_001
assertNull(ch.freshHr())
ch.dispose()
}
@Test
fun `unbind останавливает транспорт`() = runTest {
val t = FakeTransport()
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { 0 })
runCurrent()
ch.bind(0, "AA:BB")
runCurrent()
ch.unbind()
assertEquals(1, t.stops)
assertEquals(WatchStatus.NOT_SELECTED, ch.state.value.status)
ch.dispose()
}
@Test
fun `повторный bind меняет адрес`() = runTest {
val t = FakeTransport()
val ch = WatchChannel(t, HrSampleStore(), FakeEnv(), this, now = { 0 })
runCurrent()
ch.bind(0, "AA:BB")
ch.bind(0, "CC:DD")
runCurrent()
assertEquals(listOf("AA:BB", "CC:DD"), t.starts)
ch.dispose()
}
@Test
fun `ReconnectPolicy 1-5-30-30`() {
assertEquals(1_000L, ReconnectPolicy.nextDelayMs(1))
assertEquals(5_000L, ReconnectPolicy.nextDelayMs(2))
assertEquals(30_000L, ReconnectPolicy.nextDelayMs(3))
assertEquals(30_000L, ReconnectPolicy.nextDelayMs(10))
}
}

View File

@ -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))
}
}