diff --git a/1-docs/0-mng/plan.md b/1-docs/0-mng/plan.md index d83868b..a15f5fa 100644 --- a/1-docs/0-mng/plan.md +++ b/1-docs/0-mng/plan.md @@ -46,8 +46,8 @@ JSON-тела (версии программ, факты .fit) — `jsonb` вн | m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ✅ (2026-10-19: Room 9 таблиц + WorkoutCore + SyncWorker/ApiClient + DataStore; APK 4,2 МБ, unit 18/18; смок sync 5 сущностей против живого сервера пройден, errata client_guid-UUID + guid-колонки daily_log/profile) | | m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ✅ (2026-10-19: 8 экранов+VM, Compose Material 3 тёмная, unit 36/36, APK 12 МБ, живой смок контура программы; errata контрактов camelCase/flat program_version/sex m-f; запуск на устройстве — приёмка владельца) | | m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ✅ (2026-10-19: парсер Ф-1.2 + VoskEngine с грамматикой/fallback + ModelStore (filesDir, не в APK) + executor через WorkoutCore 🛡 + TTS/сигналы + Mic-кнопка/RECORD_AUDIO + блок настроек; unit 63/63, APK 57 МБ; живой URL модели 200 OK; распознавание на устройстве — приёмка владельца, КУ-1 вручную) | -| m12-watch-channel | `WatchChannel`: 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 | ⬜ | +| 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/разрешения — приёмка владельца; коммит `d4867ac` запушен) | +| m13-fenix-data-field | `FenixDataField` (Monkey C): пульс/зоны/автономный таймер отдыха; мультитаргет-сборка по моделям/разрешениям (vision 1.1, Ф-2.8; якорь fenix6x 280×280 — сборка под другие часы не должна усложняться, указание владельца 2026-10-19) | 5 | ✅ 2026-10-19 (гейты 1.3/2.4/3.4 утверждены; сборки fenix6xpro+fenix6+fenix6s без правок кода; тесты ядра 54/54 собственным раннером в симуляторе — harness monkeydo /t несовместим с девайс-паком, readme прим. 5; developer-ключ RSA 4096; интерактивный смок и sideload — приёмка владельца, readme «Sideload») | Прототипы чек-листа шага 7 (tech-requirements разд. 7) выполняются в своих итерациях; ОВ-3 (companion-канал) — прототип в итерации 1 параллельно diff --git a/1-docs/1-reqs/requirements.md b/1-docs/1-reqs/requirements.md index 60f1237..7ea125e 100644 --- a/1-docs/1-reqs/requirements.md +++ b/1-docs/1-reqs/requirements.md @@ -178,14 +178,19 @@ Garmin штатно — Ф-2.5, Ф-2.6). Код поля данных перен 1. Метрики на экране: текущий пульс (шрифт 40–48 pt), средний пульс текущего подхода (от начала подхода до текущего момента), время - текущего подхода (под нагрузкой). + с последнего Lap (границы подхода — Ф-2.3; включает предшествующий + отдых — точное время «под нагрузкой» не измеряется: детекция + повторений недоступна Connect IQ API, errata 2026-10-19). 2. Зона пульса — цвет ободка/фона: синий < 50 %, зелёный 50–70 %, жёлтый 70–85 %, красный > 85 % от `Hr.MaxBpm`; границы — из настроек поля данных (Connect IQ settings; автоматическая передача с телефона — открытый вопрос 3). -3. Границы подхода: поле считает подход открытым от штатного события - нового подхода (Strength Set) до события Lap; метрики подхода - обнуляются на границе. +3. Границы подхода — между нажатиями кнопки Lap (штатная семантика + «Силовой»: Lap — конец подхода, метки Lap попадают в .fit, КУ-2): + событие Strength Set в Connect IQ API отсутствует (errata + 2026-10-19 — класса повторений нет ни в одной версии платформы, + проверено по актуальному API); метрики подхода считаются за + интервал Lap→Lap и обнуляются на Lap. 4. Вибрация: однократная лёгкая — конец автономного таймера отдыха поля (длительность — настройка поля в Garmin Connect Mobile, умолчание `Rest.DefaultSec`); двойная — выход пульса за красную diff --git a/1-docs/3-tech/tech-requirements.md b/1-docs/3-tech/tech-requirements.md index 689bd31..adc454e 100644 --- a/1-docs/3-tech/tech-requirements.md +++ b/1-docs/3-tech/tech-requirements.md @@ -226,7 +226,7 @@ ## 7. Чек-лист шага 7 (проверки прототипами) -1. Канал часов: пульс доходит на телефон при активном экране часов (ОВ-3; errata 2026-10-19 — транспорт BLE-трансляция «Broadcast Heart Rate», таймер отдыха автономный на часах — m13) — итерация 5 (m12). +1. Канал часов: пульс доходит на телефон при активном экране часов (ОВ-3; errata 2026-10-19 — транспорт BLE-трансляция «Broadcast Heart Rate», таймер отдыха автономный на часах — m13; вибрация конца отдыха и метрики подхода Lap→Lap — приёмка m13) — итерация 5 (m12). 2. Логин Garmin Connect из C# (FitFetcher) — итерация 2. 3. Vosk: точность команд в шумном зале ≥ КУ-1 — итерация 2. 4. Сопоставление телефон ↔ .fit на реальной тренировке — итерация 3. diff --git a/1-docs/4-modules/m13-fenix-data-field/SKILL.md b/1-docs/4-modules/m13-fenix-data-field/SKILL.md new file mode 100644 index 0000000..a29923b --- /dev/null +++ b/1-docs/4-modules/m13-fenix-data-field/SKILL.md @@ -0,0 +1,24 @@ +--- +name: m13-fenix-data-field +description: FenixDataField (Monkey C, Connect IQ) — поле данных «Феникс» в профиле «Силовая»: пульс/зоны, метрики подхода Lap→Lap, автономный таймер отдыха с вибрацией, мультитаргет fenix6x +--- + +# m13-fenix-data-field + +Поле данных Connect IQ на часах (архитектура разд. 1.1): живёт в +штатном профиле «Силовая», показывает текущий пульс с окраской зоны +(50/70/85 % от `maxBpm` — настройка или формула Танаки), метрики +подхода (время и средний пульс от последнего Lap — errata Ф-2.3: +детекция повторений недоступна Connect IQ API) и автономный таймер +отдыха с вибрацией (длительность — настройка поля, умолчание 120 с). +Запись `.fit` ведёт платформа Garmin; поле ничего не передаёт и не +хранит (Ф-2.7). Часы автономны (Ф-2.4, errata ОВ-3 m12). + +Мультитаргет: якорь fenix6x 280×280; добавление моделей — строка +таргета `monkey.jungle`, макет параметрический (семейство квадратных +MIP). Тесты — Toybox.Test; сборка `monkeyc`; приёмка — реальный +Fenix 6X владельца. + +- Требования: [module_reqs.md](module_reqs.md) +- План работ: [readme.md](readme.md) +- Спецификации: [api_specs/](api_specs/) diff --git a/1-docs/4-modules/m13-fenix-data-field/api_specs/01-project-manifest.md b/1-docs/4-modules/m13-fenix-data-field/api_specs/01-project-manifest.md new file mode 100644 index 0000000..34226e4 --- /dev/null +++ b/1-docs/4-modules/m13-fenix-data-field/api_specs/01-project-manifest.md @@ -0,0 +1,81 @@ +# Спецификация 01: проект Monkey C, манифест, таргеты, сборка + +Источники: module_reqs разд. 1 (единица 1, решения гейта 1.3 № 2/4), +Ф-2.8, tech-req ОВ-7, указание владельца 2026-10-19 (мультитаргет). +Единица 1 + 9 (сборка). + +## 1. Структура проекта + +``` +3-app\5-watch\FenixDataField\ + manifest.xml — тип datafield, UUID, minApiVersion, продукты + monkey.jungle — таргетная матрица (якорь fenix6x) + resources\ + layouts\layout.xml — параметрический макет (спека 03) + strings\strings.xml — en (базовая) + ru (ПОДХОД/ОТДЫХ/зоны) + settings\settings.xml — properties (спека 04) + source\ + FenixDataField.mc — класс поля: колбэки платформы, связка ядра + и отрисовки (спека 03 §4) + SetTracker.mc — ядро (спека 02) + RestTimer.mc — ядро (спека 02) + ZoneCalc.mc — ядро (спека 02) + Vibe.mc — паттерны вибраций + гистерезис (спека 04) + FieldSettings.mc — чтение/кламп properties (спека 04) + test\ + ZoneCalcTest.mc — (:test)-функции Toybox.Test (спека 02 §5) + RestTimerTest.mc + SetTrackerTest.mc + bin\ — выход сборки (в .gitignore) +``` + +## 2. Манифест + +- **тип** — data field (поле данных штатных активностей; элемент + типа — по образцу SDK-сэмпла DataField; сверяется при установке + SDK, шаг 3); +- **UUID** — `34f470ff-b5d1-4141-ad06-c483fa79a1f8` (сгенерирован + 2026-10-19, неизменен навсегда — идентификатор поля при + обновлениях); +- **minApiVersion 2.1.0** — самый низкий API из используемого + (Toybox.Test; остальное — 1.0.0–1.3.0), поле совместимо и со + старыми CIQ-устройствами (мультичасовая инфраструктура); +- **products** — `fenix6x` (якорь 280×280, CIQ ≤ 4.1.7); добавление + модели = строка продукта + при необходимости свой макет + (семейство квадратных MIP покрывается параметрическим — правка кода + не требуется); +- **permissions** — не запрашиваются: пульс приходит в + `compute(Activity.Info)`, вибрация — `Attention.vibrate`, сеть + полю не нужна (Ф-2.7); +- **языки** — en (базовая) + ru. + +Отображаемое имя поля — «Феникс» (ru) / «Phoenix» (en): под ним +поле появляется в списке полей профиля «Силовая». + +## 3. Сборка (решение 2 гейта 1.3 — toolchain) + +- Connect IQ SDK (актуальная 9.2) устанавливается агентом в + `C:\tools\connectiq` по прецеденту m09; JDK 21 уже установлен + (C:\tools\jdk-21.0.12.1+1); +- сборка — CLI `monkeyc` по каждому таргету матрицы из + `monkey.jungle`; выход — `.prg` в `bin\`; +- тесты — прогон `(:test)`-функций инструментарием SDK (симулятор); + точную команду фиксирует шаг 3 по установленному SDK (спеки + фиксируют сами тест-кейсы, не команду); +- смок в симуляторе — поле добавляется в профиль Strength, + симулируются пульс/Lap (шаг 3); +- подпись для устройства — developer-ключ (генерация по инструкции + Garmin, шаг 3), sideload на Fenix 6X — developer mode + Garmin + Connect Mobile (инструкция — readme модуля, шаг 4). + +Критерий готовности единицы: `monkeyc` собирает все таргеты матрицы +без ошибок; предупреждения — только обоснованно подавленные. + +## 4. Тесты (готовность сборки) + +1. Сборка таргета fenix6x проходит без ошибок компиляции/линковки. +2. `.prg` появляется в `bin\`, размер разумный (< 200 КБ). +3. Добавление второго таргета в `monkey.jungle` (любая модель + семейства квадратных MIP) собирается **без правок исходников** — + проверка мультитаргет-инварианта (инвариант 5) на CI-сборке + агента. diff --git a/1-docs/4-modules/m13-fenix-data-field/api_specs/02-core.md b/1-docs/4-modules/m13-fenix-data-field/api_specs/02-core.md new file mode 100644 index 0000000..7399294 --- /dev/null +++ b/1-docs/4-modules/m13-fenix-data-field/api_specs/02-core.md @@ -0,0 +1,115 @@ +# Спецификация 02: ядро — SetTracker / RestTimer / ZoneCalc + +Источники: module_reqs разд. 1 (единицы 2–4), Ф-2.1–Ф-2.4 (с errata +Ф-2.3), решение гейта 1.3 № 1/5. Чистые классы Monkey C без UI и без +обращений к Toybox.WatchUi — тестируются Toybox.Test. + +Общее для всех: часы ядра — `Activity.Info.timerTime` (активное +время активности, мс; при паузе стоит — таймеры поля замирают сами, +отдельная обработка паузы не нужна). Значения времени приходят +извне (параметрами) — ядро системное время не читает. + +## 1. `ZoneCalc` — зоны пульса + +Вход: `maxBpmFor(hrMaxBpm, age)`: + +- `hrMaxBpm > 0` → `hrMaxBpm` (ручной ввод); +- иначе формула Танаки: `208 − 0,7 × age`, округление до целого + вверх (`Math.ceil`); `age` вне 14–100 → умолчание 45. + +`zoneFor(bpm, maxBpm)` → зона: + +| Зона | Диапазон от maxBpm | Цвет (MIP) | Подпись ru / en | +|---|---|---|---| +| BLUE | bpm < 50 % | Graphics.COLOR_BLUE | «восстановление» / «recovery» | +| GREEN | 50 % ≤ bpm < 70 % | COLOR_GREEN | «лёгкая» / «easy» | +| YELLOW | 70 % ≤ bpm < 85 % | COLOR_YELLOW | «средняя» / «moderate» | +| RED | 85 % ≤ bpm | COLOR_RED | «высокая» / «high» | + +- сравнение целочисленное без Float: `bpm*2 vs maxBpm` (50 %), + `bpm*10 vs maxBpm*7` (70 %), `bpm*20 vs maxBpm*17` (85 %); +- `bpm == null` или ≤ 0 → зона NONE (серый, прочерк) — «нет данных»; +- подписи зон совпадают с «Профилем» телефона m10 (согласованный + словарь системы). + +## 2. `SetTracker` — метрики подхода (границы Lap→Lap, errata Ф-2.3) + +Состояние: `lapAtMs` (timerTime последнего Lap), `sumBpm`, `nBpm`. + +- `onLap(timerTimeMs)`: `lapAtMs = timerTimeMs; sumBpm = 0; nBpm = 0` + — метрики обнуляются на границе (Ф-2.3); +- `onSample(bpm)`: `bpm != null && bpm > 0` → `sumBpm += bpm; + nBpm += 1`; null-пульс пропускается (в среднее не попадает); +- `elapsedMs(timerTimeMs) as Long`: `timerTimeMs − lapAtMs`; +- `avgBpm() as Number or Null`: `nBpm > 0` → округление + `(sumBpm + nBpm/2) / nBpm`, иначе null («—» до первого сэмпла); +- стартовая граница — `onTimerStart` активности (`lapAtMs` = 0): + метрики считаются с начала активности до первого Lap. + +«Время подхода» = `elapsedMs` (включает предшествующий отдых — +errata Ф-2.1); «средний пульс подхода» = `avgBpm` за тот же +интервал. + +## 3. `RestTimer` — автономный таймер отдыха (Ф-2.4 errata) + +Состояние: `active`, `restEndMs`, `fired`. + +- `start(timerTimeMs, restSec)`: `active = true; restEndMs = + timerTimeMs + restSec*1000; fired = false`; +- `remainingMs(timerTimeMs) as Long`: `active` → `restEndMs − + timerTimeMs`, иначе 0; +- `isResting(timerTimeMs)`: `active && remainingMs > 0` — на экране + строка «ОТДЫХ mm:ss» (остаток); +- `onEnd(timerTimeMs)`: `active && remainingMs ≤ 0 && !fired` → + `fired = true`, вернуть `true` — сигнал на однократную лёгкую + вибрацию (спека 04); повторные вызовы `false`; +- пауза активности: `timerTime` стоит → остаток не меняется (отдых + «заморожен», Ф-2.6 штатная пауза); отдельной логики нет; +- до первого Lap таймер не активен (`active = false` при старте + активности). + +Умолчание длительности — `FieldSettings.restSec` (120 = +`Rest.DefaultSec` телефона, спека 04). + +## 4. `Vibe` — паттерны и гистерезис (спека 04 — параметры) + +Красная зона: `onZone(zone, timerTimeMs)`: + +- зона RED и прошло ≥ 60 000 мс от последней двойной вибрации → + сигнал двойной вибрации, фиксируется время; +- выход из RED (зона GREEN/BLUE/NONE) → гистерезис сброшен — + следующее входжение в RED вибрирует сразу. + +## 5. Тесты (Toybox.Test, `test\`) + +`ZoneCalcTest`: +1. Танака: `maxBpmFor(0, 45) == 177` (208 − 31,5 = 176,5 → ceil); +2. ручной приоритет: `maxBpmFor(190, 45) == 190`; +3. возраст вне диапазона → расчёт от 45 (`maxBpmFor(0, 5) == 177`); +4. границы зон при maxBpm 180: 89 → BLUE, 90 → GREEN, 125 → GREEN, + 126 → YELLOW, 152 → YELLOW, 153 → RED (50 % = 90, 70 % = 126, + 85 % = 153 — целочисленные пороги); +5. `zoneFor(null, 180) == NONE`. + +`SetTrackerTest`: +1. среднее округление: сэмплы [100, 101, 101] → 101; +2. null-сэмплы не влияют: [100, null, 102] → 101; +3. `onLap` обнуляет: сэмплы до/после Lap — среднее только после; +4. `elapsedMs` от границы: Lap на 60 000, сейчас 75 500 → 15 500; +5. до сэмплов `avgBpm() == null`. + +`RestTimerTest`: +1. старт по Lap на t=60 000, restSec 120: `remainingMs(60 000)==120 000`, + `remainingMs(120 000) == 60 000`, `remainingMs(180 000) == 0`; +2. `onEnd` срабатывает однократно: false при остатке > 0; true при + 180 000; повторный вызов false; +3. `isResting`: true при остатке > 0, false при ≤ 0; +4. после конца отдыха `isResting == false`, но `active` до + следующего Lap не перезапускается; +5. новый `start` сбрасывает `fired`. + +`Vibe` (гистерезис) — в тестах RestTimer/Vibe: +6. RED в t=0 → вибрация; RED в t=30 000 → нет (< 60 с); RED в + t=61 000 → вибрация; +7. GREEN между — сброс: RED t=0 → вибрация; GREEN t=10 000; RED + t=20 000 → вибрация (сброс сработал). diff --git a/1-docs/4-modules/m13-fenix-data-field/api_specs/03-screen.md b/1-docs/4-modules/m13-fenix-data-field/api_specs/03-screen.md new file mode 100644 index 0000000..7b9238d --- /dev/null +++ b/1-docs/4-modules/m13-fenix-data-field/api_specs/03-screen.md @@ -0,0 +1,82 @@ +# Спецификация 03: экран поля — макет и отрисовка + +Источники: module_reqs разд. 1 (единица 5) и 2, Ф-2.1/Ф-2.2 (с +errata), ui-ux разд. 2 (glanceable, цвет дублируется текстом), +tech-req 6 п. 1. Единица 5. + +## 1. Макет (resources\layouts\layout.xml) + +Параметрический макет семейства квадратных MIP-экранов — якоря +(`loc="center"` и верх/низ), без единой фиксированной координаты: +позиции и шрифты вычисляются от `dc.getWidth()/getHeight()` в +`onLayout`. Один макет на всё семейство (fenix6x 280×280, fenix6 +260×260, fenix6s 240×240 — добавление таргета без правок, инвариант +5). + +Элементы (сверху вниз): + +| Элемент | Содержимое | Шрифт | +|---|---|---| +| `zoneLbl` | подпись зоны + цветная плашка-ободок | FONT_TINY | +| `hrLbl` | текущий пульс или «—» | FONT_NUMBER_LARGE (запасной FONT_NUMBER_MEDIUM — §3) | +| `unitLbl` | «уд/мин» | FONT_TINY | +| `stateLbl` | «ОТДЫХ mm:ss» / «ПОДХОД mm:сс · ср N» | FONT_SMALL | + +## 2. Содержимое и цвета + +- фон — чёрный (`COLOR_BLACK`), текст — белый; MIP, читаемость на + солнце без подсветки; +- **зона**: плашка/ободок цвета зоны (`COLOR_BLUE/GREEN/YELLOW/RED` + по ZoneCalc) вокруг подписи зоны; подпись текстом — цвет не + единственный носитель (ui-ux): «восстановление»/«лёгкая»/ + «средняя»/«высокая»; нет пульса → NONE: серая плашка + (`COLOR_DK_GRAY`), подпись «—»; +- **пульс**: крупное число `currentHeartRate`; null → «—» + (unitLbl гасится); +- **строка состояния**: + - идёт отдых (`RestTimer.isResting`) → «ОТДЫХ 1:48» — обратный + отсчёт остатка (mm:сс, при остатке ≥ 10 мин «мм:сс» с ведущими + разрядами не рвётся — формат `m:ss`/`mm:ss`); + - иначе → «ПОДХОД 2:05 · ср 132»: `SetTracker.elapsedMs` (время с + последнего Lap — errata Ф-2.1) и `SetTracker.avgBpm`; среднее + null → «ср —»; +- форматирование времени — чистая функция `mmss(ms)` (округление + вниз до секунд; часы не нужны — интервалы < 10 мин). + +## 3. Шрифт пульса (адаптивный) + +`onLayout`: берётся `FONT_NUMBER_LARGE`; если высота глифов + +прочих элементов не влезает в высоту области (мелкие страницы +данных, 2+ поля на странице) — шаг вниз до `FONT_NUMBER_MEDIUM`. +Пульс — главное число экрана (Ф-2.1: шрифт пульса максимальный из +доступных на области). + +## 4. Класс `FenixDataField` (связка) + +- `initialize()`: создание ядра (SetTracker/RestTimer/ZoneCalc/ + Vibe/FieldSettings), начальное состояние — «нет данных» + (переменные инициализируются здесь, не в `compute` — асинхронность + compute/onUpdate по документации); +- `compute(info)`: раз в секунду — `currentHeartRate` → + `SetTracker.onSample`, кэш пульса; `RestTimer.onEnd(timerTime)` + → лёгкая вибрация; `Vibe.onZone(zone, timerTime)` → двойная при + красной (гистерезис); +- `onTimerLap()`: `SetTracker.onLap` + `RestTimer.start` — Lap = + конец подхода = старт отдыха (errata Ф-2.3); +- `onTimerStart/Reset`: `SetTracker` к нулю, `RestTimer` не активен; +- `onUpdate(dc)`: отрисовка по кэшу (§2); данные могли ещё не + прийти — рисуем прочерки; +- `onTimerPause/Resume`: без кода — `timerTime` стоит/идёт, таймеры + замирают сами (спека 02 §3). + +## 5. Симуляторный смок (критерий приёмки 2, шаг 3) + +1. Поле добавляется в профиль Strength симулятора, отображается без + ошибок отрисовки; +2. симуляция пульса: число обновляется ≈1/с, плашка зоны и подпись + соответствуют порогам (прогнать пульс через все 4 зоны); +3. Lap (симулятор): метрики обнуляются, строка «ОТДЫХ …» тикает + вниз, по нулю — однократная вибрация (лог симулятора); +4. пульс в красной зоне — двойная вибрация, повтор не чаще 60 с; +5. пауза активности — отсчёты стоят; resume — продолжаются; +6. нулевой/отсутствующий пульс — «—», поле живое (не падает). diff --git a/1-docs/4-modules/m13-fenix-data-field/api_specs/04-settings-vibes.md b/1-docs/4-modules/m13-fenix-data-field/api_specs/04-settings-vibes.md new file mode 100644 index 0000000..155356f --- /dev/null +++ b/1-docs/4-modules/m13-fenix-data-field/api_specs/04-settings-vibes.md @@ -0,0 +1,63 @@ +# Спецификация 04: настройки поля и вибрации + +Источники: module_reqs разд. 1 (единицы 6–7), Ф-2.2 (границы зон — +настройки поля), Ф-2.4 errata (длительность отдыха — настройка, +умолчание `Rest.DefaultSec`), решение гейта 1.3 № 3 (sideload живёт +на умолчаниях) и № 5 (красная зона процентная). Единицы 6–7. + +## 1. Properties (resources\settings\settings.xml) + +| Свойство | Тип | Диапазон редактора | Умолчание | Смысл | +|---|---|---|---|---| +| `restSec` | numeric | 30–600 | 120 | длительность автономного таймера отдыха, с (= `Rest.DefaultSec` телефона) | +| `hrMaxBpm` | numeric | 0–250 | 0 | макс. ЧСС для зон; 0 — расчёт по формуле Танаки от `age` | +| `age` | numeric | 14–100 | 45 | возраст для формулы Танаки (208 − 0,7 × возраст) | + +- подписи/описания — ru/en (строки в settings.xml + strings); +- значения читаются `FieldSettings` при `initialize()`; смена + настроек применяется при следующем запуске активности (поле + перезапускается платформой); +- правка значений на устройстве — только через Garmin Connect + Mobile/Express для приложений из магазина CIQ (решение 3 гейта + 1.3): sideload-сборка живёт на умолчаниях, смена поведения = + пересборка. + +## 2. `FieldSettings` (клампы — защита от кривых значений) + +Чистый класс: чтение property → кламп → поле: + +- `restSec`: вне 30–600 или не число → 120; +- `hrMaxBpm`: вне 0–250 → 0 (авто); +- `age`: вне 14–100 → 45. + +`maxBpm()` delegирует в `ZoneCalc.maxBpmFor(hrMaxBpm, age)`. + +## 3. Вибрации (`Vibe.mc`) + +`Attention.vibrate([...])`, до 8 `VibeProfile(duty, мс)`: + +| Событие | Паттерн | Вызов | +|---|---|---| +| конец отдыха (RestTimer.onEnd == true) | `[VibeProfile(25, 300)]` — однократная лёгкая | из `compute` | +| красная зона (Vibe-гистерезис сработал) | `[VibeProfile(75, 200), VibeProfile(0, 150), VibeProfile(75, 200)]` — двойная заметная | из `compute` | + +- вибрация различается паттерном (ui-ux: паттерны различаются — + отдых/предупреждение); +- недоступность вибрации на модели — вызов молча пропускается + (проверка наличия/`try-catch`; инвариант 4, Ф-2.8); +- гистерезис красной зоны: повторная двойная не раньше 60 000 мс; + выход в GREEN/BLUE/NONE сбрасывает (спека 02 §4); +- вибрации не стекаются: не чаще одной причины за проход `compute`. + +## 4. Тесты (Toybox.Test, дополняют спеку 02) + +`FieldSettings` (чистые клампы): +1. `clampRest(10) == 120`, `clampRest(600) == 600`, `clampRest(30) == 30`; +2. `clampRest(null) == 120` (property отсутствует); +3. `clampHr(-5) == 0`, `clampHr(250) == 250`; +4. `clampAge(200) == 45`, `clampAge(14) == 14`; +5. `maxBpm()`: hrMaxBpm 190 → 190; hrMaxBpm 0 + age 45 → 177. + +`mmss` (форматирование строки состояния, спека 03 §2): +6. `mmss(0) == "0:00"`, `mmss(59_999) == "0:59"`, `mmss(60_000) == "1:00"`, + `mmss(65_000) == "1:05"`, `mmss(600_000) == "10:00"`. diff --git a/1-docs/4-modules/m13-fenix-data-field/module_reqs.md b/1-docs/4-modules/m13-fenix-data-field/module_reqs.md new file mode 100644 index 0000000..3d2db9b --- /dev/null +++ b/1-docs/4-modules/m13-fenix-data-field/module_reqs.md @@ -0,0 +1,178 @@ +# Требования к модулю: m13-fenix-data-field + +> Модуль — **FenixDataField** (архитектура разд. 1.1): поле данных +> Connect IQ (Monkey C) «Феникс» в штатном профиле «Силовая»: текущий +> пульс + зона цветом, метрики подхода (время, средний пульс), +> автономный таймер отдыха с вибрацией. Самодостаточная копия +> требований (d-dev-6). Источники: Ф-2.1–Ф-2.8 (requirements), errata +> Ф-2.4 (m12, автономный таймер), ОВ-7 (tech-req 1, таргеты), +> tech-req 6 п. 1 (UI/UX часов), 7 п. 1 (закрытие части «таймер» +> прототипа канала), ui-ux-recommendations разд. 2. + +## 1. Назначение и границы + +Поле данных на часах во время силовой активности: отображает пульс, +зону и метрики подхода, вибрирует в конце отдыха и при выходе пульса +в красную зону. Только отображение и вибрации — запись `.fit` ведёт +платформа Garmin (Ф-2.7); часы полностью автономны (Ф-2.4: не +зависимость — никаких требований к телефону). + +### 1.1. Границы подхода (исследование API, 2026-10-19) + +Ф-2.3 предполагала границами подхода «штатное событие нового подхода +(Strength Set) → событие Lap». Проверка по актуальной документации +Connect IQ API (вплоть до текущей версии платформы): + +1. **События/данных повторений в Connect IQ API нет** — ни в + `Toybox.Activity` (6 классов: Info, ProfileInfo, SplitInfo, + WorkoutStep…; у `Activity.Info` нет полей сетов/повторов), ни + отдельным модулем; классов повторений нет в общем списке классов. + Это не ограничение fenix 6X — API не существует ни в одной версии + платформы: штатная детекция повторений «Силовой» сторонним кодом + недоступна. +2. **Доступно полю данных:** `compute(info as Activity.Info)` — раз + в секунду (`currentHeartRate` — Number|Null, API 1.0.0); + `onTimerLap()` — нажатие кнопки Lap (API 1.3.0); таймерные + колбэки `onTimerStart/Stop/Pause/Resume/Reset` (API 1.3.0); + `Attention.vibrate([VibeProfile(duty, мс)])` — до 8 профилей + подряд, поля данных поддержаны (API 1.0.0); собственная разметка + экрана (`onLayout` + отрисовка `dc`, параметрическая по + `dc.getWidth/getHeight`). +3. **Решение v1 (errata Ф-2.3, разд. 5):** границы подхода = между + нажатиями Lap — штатная семантика «Силовой» (Lap жмётся в конце + подхода, метки попадают в `.fit`, КУ-2 сохраняется). «Время + подхода» = интервал с последнего Lap (включает предшествующий + отдых — точное время «под нагрузкой» без детекции повторений + не измеряется); «средний пульс подхода» = среднее + `currentHeartRate` за тот же интервал. Lap обнуляет метрики и + стартует таймер отдыха. + +**В границах модуля:** + +- **проект Monkey C** `3-app\5-watch\FenixDataField`: манифест (тип + datafield, UUID, `minApiVersion` — не выше 2.1.0, чтобы поле + ставилось и на старые CIQ-устройства), таргетная матрица + `monkey.jungle` — якорь `fenix6x` (280×280); добавление модели — + строка таргета, код и макет не меняются (мультитаргет-инфраструктура, + указание владельца 2026-10-19); +- **ядро (чистые классы Monkey C, без UI):** + - `SetTracker` — метрики подхода: время с границы (Lap), средний + пульс интервала (накопление сэмплов из `compute`), обнуление по + `onTimerLap`; null-пульс пропускается; + - `RestTimer` — автономный таймер отдыха: старт по Lap, + длительность из настройки `restSec` (умолчание 120 = + `Rest.DefaultSec` телефона), обратный отсчёт, конец → вибрация; + пауза активности (`onTimerPause/Resume`) останавливает/ведёт + дальше; + - `ZoneCalc` — зоны от `maxBpm`: < 50 % синяя (recovery), 50–70 % + зелёная, 70–85 % жёлтая, > 85 % красная; `maxBpm` из настройки + `hrMaxBpm`, не задана (0) — формула Танаки + `208 − 0,7 × возраст` (настройка `age`); возвращает цвет + (MIP-палитра) и текстовую подпись зоны; +- **экран поля** — параметрическая разметка семейства квадратных + MIP-экранов (якоря + размеры от `dc`, без фиксированных + координат под конкретную модель): текущий пульс — крупнейшее + число (`FONT_NUMBER_*`), плашка/ободок зоны цветом + подпись зоны + текстом (цвет — не единственный носитель), строка состояния + «ПОДХОД мм:сс · ср N» / «ОТДЫХ мм:сс» (обратный отсчёт), прочерк + при отсутствии пульса; +- **вибрации:** конец отдыха — однократная лёгкая; красная зона — + двойная с гистерезисом (повторная не раньше 60 с, сброс при + возврате в жёлтую зону); +- **настройки поля** (стандартные properties, settings.xml): + `restSec` (30–600, умолчание 120), `hrMaxBpm` (0–250, умолчание + 0 = расчёт по формуле), `age` (14–100, умолчание 45), подписи + ru/en; умолчания зашиты в сборку — работают без магазина; +- **тесты Toybox.Test** (модуль есть с API 2.1.0; asserts + компилируются out в release): зоны, таймер отдыха, трекер + подхода; прогон инструментарием SDK; +- **сборка и доставка:** `monkeyc` по всем таргетам без ошибок, + подпись developer-ключом, инструкция sideload на Fenix 6X + (developer mode + Garmin Connect Mobile) — в readme модуля. + +**Вне границ модуля:** запись `.fit` (платформа Garmin), сетевой +обмен из поля (`Communications` на fenix 6X недоступен полям данных — +errata ОВ-3 m12; канал живого пульса — m12, BLE-трансляция), +редактирование настроек на часах (поля данных не имеют собственных +экранов настроек), публикация в магазине Connect IQ (опция владельца +— см. решение 3 гейта 1.3), добавление таргетов fenix6/6S (по +фактическому парку — строка конфигурации), пульс-оповещения +Safety.HrStrengthMax (абсолютный порог 120 — телефон, Ф-3/Ф-13; +часы красят по процентам Ф-2.2). + +## 2. Решения интерфейса (ui-ux разд. 2, tech-req 6 п. 1) + +- Один экран-поле, читается за 1–2 с (glanceable): пульс — главное + число; всё остальное — строкой состояния мельче. +- Семантика кнопок не меняется: Lap — конец подхода (штатно), + Start/Stop — пауза/стоп активности; поле не требует ни одного + нажатия и не перехватывает кнопки (Ф-2.5, Ф-2.6). +- Цвет зоны дублируется подписью (recovery/лёгкая/средняя/высокая — + те же названия, что на «Профиле» телефона m10). +- MIP-экран: тёмный фон, высокий контраст, крупные шрифты + (`FONT_NUMBER_*` для пульса), 64-цветная палитра; читаемость на + солнце не требует подсветки. +- Нет пульса (датчик не готов / null) — прочерк «—», остальное + живое; поле не падает (Ф-2.8). + +## 3. Инварианты (🛡 — нарушать запрещено) + +1. **Поле не пишет в сеть и не хранит историю** (Ф-2.7): только + отображение и вибрации; запись активности ведёт платформа Garmin. +2. **Кнопки не перехватываются** (Ф-2.5, Ф-2.6): Lap/Start/Stop — + штатная обработка; поле не блокирует паузу, остановку и + сохранение активности. +3. **Часы автономны** (Ф-2.4): поле не требует телефона и никак его + не вызывает; работа поля не зависит от наличия связи с чем бы то + ни было. +4. **Отсутствующая функция молча пропускается** (Ф-2.8): нет + пульса — прочерк; вибрация недоступна на модели — просто не + вызывается; поле не падает. +5. **Один исходный код** (Ф-2.8, указание владельца 2026-10-19): + функциональное поведение одинаково на всех таргетах; разметка — + параметрический макет семейства экранов; добавление модели часов + — конфигурация сборки без правок кода. +6. **Секретов нет:** поле ничего не передаёт — ключей, паролей и + персональных данных в коде/настройках нет (настройки — возраст, + макс. ЧСС, отдых). + +## 4. Критерии приёмки модуля + +1. `monkeyc` собирает все таргеты матрицы без ошибок и предупреждений + (кроме подавленных обоснованно); тесты `Toybox.Test` зелёные + (зоны, таймер отдыха, метрики подхода). +2. Симулятор Connect IQ: поле добавляется в профиль Strength; + пульс/зона/строка подхода отображаются и обновляются раз в + секунду; Lap обнуляет метрики и запускает отдых; вибрации + вызываются (видно в логе симулятора). +3. Приёмка на устройстве владельца (Fenix 6X, якорь): sideload + dev-сборки; «Силовая» с полем «Феникс»: пульс и зона на экране, + Lap — граница подхода, вибрация конца отдыха, двойная вибрация + в красной зоне; метки Lap присутствуют в `.fit` выгрузки Garmin + Connect (проверяется импортом m06/консолью m08). +4. Деградация: без пульс-данных — прочерк, поле живое; пауза + активности — таймер стоит; модель без вибрации — остальное + работает (Ф-2.8, смок владельца на имеющемся парке). + +## 5. Errata НД (по утверждении гейта 1.3; ВНЕСЕНА 2026-10-19) + +- requirements **Ф-2.1**: «время текущего подхода (под нагрузкой)» → + «время с последнего Lap (границы подхода — Ф-2.3; включает + предшествующий отдых — точное время „под нагрузкой“ не измеряется: + детекция повторений недоступна Connect IQ API, errata 2026-10-19)»; +- requirements **Ф-2.3**: «от штатного события нового подхода + (Strength Set) до события Lap» → «между нажатиями кнопки Lap + (штатная семантика „Силовой“: Lap — конец подхода, метки Lap + попадают в .fit, КУ-2): событие Strength Set в Connect IQ API + отсутствует (errata 2026-10-19 — класса повторений нет ни в одной + версии платформы, проверено по актуальному API); метрики подхода + считаются за интервал Lap→Lap и обнуляются на Lap»; +- requirements **Ф-2.2** — правка не потребовалась: норма уже + содержит «границы — из настроек поля данных (Connect IQ settings; + автоматическая передача с телефона — открытый вопрос 3)»; +- tech-requirements **7 п. 1**: дополнено «вибрация конца отдыха и + метрики подхода Lap→Lap — приёмка m13». + +Волна внесена одним пакетом после «утверждаю» гейта 1.3 (без подъёма +версий документов — errata в телах разделов, версии поднимутся на +ближайшем ревью; прецедент — волна m12). diff --git a/1-docs/4-modules/m13-fenix-data-field/readme.md b/1-docs/4-modules/m13-fenix-data-field/readme.md new file mode 100644 index 0000000..f1292b0 --- /dev/null +++ b/1-docs/4-modules/m13-fenix-data-field/readme.md @@ -0,0 +1,187 @@ +# m13-fenix-data-field — план работ (d-dev) + +> Модуль: FenixDataField — поле данных Connect IQ (Monkey C) +> «Феникс» в штатном профиле «Силовая»: пульс + зона, метрики подхода +> (Lap→Lap — errata Ф-2.3, см. [module_reqs.md](module_reqs.md) +> разд. 1.1/5), автономный таймер отдыха с вибрацией. Мультитаргет: +> якорь fenix6x 280×280, добавление моделей — строкой таргета. +> Алгоритм: `std1\_rules\d\d-dev.md` (адаптация: домен/функции/БД → +> единицы Monkey C, гейты те же). +> Зависимости: m12 (DONE — канал телефона не затрагивается; errata +> Ф-2.4 об автономном таймере — исполнение здесь). Сервер/телефон — +> без изменений. + +| Шаг | Что делаем | Статус | +|---|---|---| +| 0 | Модульные документы: module_reqs.md / readme.md / SKILL.md | ✅ 2026-10-19 | +| 1 | Список единиц (манифест/ядро×3/экран/вибрации/настройки/тесты/доставка) | ✅ 2026-10-19 | +| 1.3 | **Пользователь!** утверждение списка (+ errata Ф-2.3, toolchain, настройки-на-устройстве, таргеты) | ✅ 2026-10-19 (все 5 решений утверждены) | +| 2 | Спецификации: api_specs (манифест/таргеты, ядро×3, экран, настройки/вибрации) | ✅ 2026-10-19 (01-проект/манифест/сборка, 02-ядро SetTracker/RestTimer/ZoneCalc, 03-экран, 04-настройки/вибрации) | +| 2.4 | **Пользователь!** утверждение спецификаций | ✅ 2026-10-19 (утверждены 4 api_specs) | +| 3 | Реализация + тесты + сборка таргетов + симулятор | ✅ 2026-10-19 (сборки 3 таргетов; тесты 54/54; смок-в-симуляторе ограничен средой — примечания 5–6) | +| 3.4 | **Пользователь!** утверждение реализации (закрытие модуля) | ✅ 2026-10-19 («заккомить» — утверждение и команда коммита) | +| 4 | Завершение: план, примечания, волна errata НД | ✅ 2026-10-19 (errata внесена после гейта 1.3 — примечание 2; план/примечания обновлены) | + +Статусы: ⬜ не начат · 🔄 в работе · ✅ готов. + +## Список единиц (шаг 1) + +| # | Единица | Что делает | +|---|---|---| +| 1 | Проект + манифест + таргеты | `3-app\5-watch\FenixDataField`: тип datafield, UUID «Феникс», minApiVersion ≤ 2.1.0; monkey.jungle — матрица таргетов (якорь fenix6x), новая модель — строка | +| 2 | `SetTracker` | метрики подхода: время с границы (Lap), средний пульс интервала (сэмплы из `compute`), обнуление по `onTimerLap`; null-пульс пропускается | +| 3 | `RestTimer` | автономный отдых: старт по Lap, длительность `restSec` (умолчание 120 = `Rest.DefaultSec`), обратный отсчёт, конец → однократная лёгкая вибрация; пауза активности останавливает | +| 4 | `ZoneCalc` | зоны 50/70/85 % от `maxBpm`: `hrMaxBpm` или Танака 208−0,7×`age`; цвет MIP-палитры + текстовая подпись (те же названия, что на «Профиле» m10) | +| 5 | Экран поля | параметрический макет семейства квадратных MIP (якоря, dc-размеры): пульс крупно (`FONT_NUMBER_*`), плашка зоны цветом + подпись, строка «ПОДХОД мм:сс · ср N» / «ОТДЫХ мм:сс», прочерк без пульса | +| 6 | Вибрации | `Attention.vibrate` + `VibeProfile`: конец отдыха — однократная лёгкая; красная зона — двойная, гистерезис (повтор ≥ 60 с, сброс ниже жёлтой) | +| 7 | Настройки поля | properties: `restSec` (30–600, умолчание 120), `hrMaxBpm` (0–250, 0 = формула), `age` (14–100, 45); подписи ru/en; умолчания работают без магазина | +| 8 | Тесты Toybox.Test | ZoneCalc / RestTimer / SetTracker: asserts (компилируются out в release), прогон инструментарием SDK | +| 9 | Сборка и доставка | `monkeyc` по всем таргетам; developer-ключ; инструкция sideload (developer mode + GCM) и приёмки на Fenix 6X — в этом readme | + +## Решения на гейт 1.3 (для владельца) + +1. **Errata Ф-2.3 / Ф-2.1 — границы подхода.** «Штатного события + Strength Set» в Connect IQ API нет (проверено по актуальному API: + классов повторений не существует ни в одной версии платформы — + родная детекция повторений «Силовой» стороннему коду недоступна). + Рекомендация: границы подхода = между нажатиями **Lap** (штатная + семантика «Силовой»: Lap — конец подхода, метки в `.fit` + сохраняются, КУ-2 жив). «Время подхода» — интервал с последнего + Lap (включает предшествующий отдых; точно «под нагрузкой» не + измерить), «средний пульс подхода» — среднее за интервал. + Отвергнутая альтернатива: эвристика «начало подхода = рост + пульса» — ненадёжна (зал, интервалы отдыха разные). Пакет errata + НД — module_reqs разд. 5, вносится после «утверждаю». +2. **Toolchain.** Агент устанавливает Connect IQ SDK для Windows + (актуальная 9.2; SDK Manager — zip с developer.garmin.com) в + `C:\tools` по прецеденту m09 (JDK 21 уже стоит). Сборка — CLI + `monkeyc`; смок — симулятор SDK; приёмка — реальный Fenix 6X + (ui-ux: симулятор только для разработки). +3. **Настройки поля на устройстве.** Стандартные properties поля + (`restSec`, `hrMaxBpm`, `age`) на реальном устройстве + редактируются только через Garmin Connect Mobile/Express **для + приложений из магазина Connect IQ**; sideload-дев-сборка живёт на + зашитых умолчаниях (120 с и т.д.). Смена поведения в v1 = + пересборка агентом. Опция владельца: публикация поля приватной + бетой в магазине (бесплатный Garmin-аккаунт разработчика) — даёт + правку настроек в GCM; в границы v1 не входит. +4. **Таргетная матрица v1.** Собираем только `fenix6x` (якорь + владельца); макет параметрический на семейство квадратных MIP — + fenix 6 (260×260) / 6S (240×240) добавляются строкой таргета без + правок кода (tech-req ОВ-7 «по фактическому парку», указание + 2026-10-19 о лёгкой сборке под другие часы). +5. **Красная зона на часах — процентная** (Ф-2.2: > 85 % от + `maxBpm` поля), а не абсолютный `Safety.HrStrengthMax` = 120 + телефона: часам абсолютный порог неизвестен (передать нельзя — + канал односторонний), по НД у часов процентная норма. Зоны + настраиваются вручную в поле (`hrMaxBpm`/`age`), синхронизация с + профилем телефона — открытый вопрос 3 (v2). + +## Примечания по ходу работ + +0. **Исследование API (2026-10-19, веб)** — обоснование errata + Ф-2.3, см. module_reqs разд. 1.1: `Toybox.Activity` — 6 классов, + повторений нет нигде (вплоть до актуального API платформы); + у DataField есть `onTimerLap()` (API 1.3.0), `compute()` раз/с + (`currentHeartRate` с 1.0.0), `Attention.vibrate` (1.0.0); + `Toybox.Test` — с API 2.1.0, asserts компилируются out в release. + SDK: актуальная 9.2, Windows SDK Manager + `developer.garmin.com/downloads/connect-iq/sdk-manager/connectiq-sdk-manager-windows.zip`. +1. Расположение проекта — `3-app\5-watch\FenixDataField` (продолжение + нумерации 1-lib/2-cli/3-web/4-phone); «5-watch» — место будущих + полей/приложений под другие часы (мультичасовая инфраструктура). +2. **Волна errata НД внесена (2026-10-19, после «утверждаю» гейта + 1.3):** requirements Ф-2.1 («время с последнего Lap — точное + „под нагрузкой“ не измеряется») и Ф-2.3 (границы подхода = + Lap→Lap, события Strength Set в CIQ API нет); Ф-2.2 — правка не + потребовалась (норма уже содержит «настройки поля», открытый + вопрос 3); tech-req 7 п. 1 — дополнено «вибрация конца отдыха и + метрики Lap→Lap — приёмка m13». Версии документов не поднимались + (ближайшее ревью). UUID поля зафиксирован в спеке 01: + `34f470ff-b5d1-4141-ad06-c483fa79a1f8`. +3. **Тулчейн фактически (2026-10-19).** Рабочая связка: **SDK 4.2.2** + (Wayback-архив zip; актуальная 9.2 скачана, но её симулятор + отвергает dev-подписи нашего пака — примечание 7) + JDK 21 + + девайс-паки fenix6xpro/fenix6/fenix6s из docker-образа + `piccobit/docker-connectiq:4.1.7` → `%APPDATA%\Garmin\ConnectIQ\Devices` + (путь виндового стора найден декомпиляцией Monkeybrains). + Идентификатор таргета — **fenix6xpro** (6X существует только в + Pro-линии). Ключ разработчика: **RSA 4096**, PKCS#8 DER + (`C:\Users\virus-home\.watchbody\ciq\developer_key.der`; PEM и + 2048-битные отвергаются — см. примечание 4). +4. **Developer-ключ обязан быть RSA 4096.** Симуляционная прошивка + отвергала 2048-битную подпись «Signature check failed» (проверено + на штатном сэмпле Garmin — падает одинаково; JDK ни при чём: + сборка под Java 11 в docker даёт то же). В `DeveloperSignature` + (monkeybrains.jar) модуль = 512 байт = 4096 бит. Ключ + перегенерирован 2026-10-19; старый 2048 сохранён как + `developer_key2048.obsolete.der` — подписи им больше не собираются. +5. **Тесты: собственный раннер вместо `monkeydo /t`.** Штатный + (:test)-harness Toybox.Test не стартует в связке «симулятор 4.2.2 + + девайс-пак docker»: ЛЮБОЙ тестовый prg (включая минимальный проект + из 15 строк) падает на старте «Unexpected Type Error: Failed to + start CIQ Application»; поле данных, к тому же, нельзя запускать + как приложение. Решение: `test-source/` — watch-app-вход + `TestEntry` (test-manifest.xml, тип watch-app) + `CoreChecks` + (счётчик/печать) + те же 13 тестовых функций из спеки 02; + тестовая сборка `monkey-test.jungle` (без --unit-test), прогон — + `monkeydo` без /t, вывод println → консоль. Итог: **54/54 + проверок** (зоны 12, метрики подхода 6, отдых 10, вибрации 7, + формат 10, клампы 9 — состав уточнён против спеки 02 числом + проверок, случаи те же). Раннер отработал как положено: нашёл + 3 реальных бага ядра (шкала порогов ZoneCalc ×10, лишнее слагаемое + в округлении avgBpm, сборка строки mmss не со строки) — исправлены, + повторный прогон зелёный. Поле данных (release, monkey.jungle) в + тестовые сборки не входит. Хвостовой «app crash» после RESULT — + штатный выход watch-app без view, на результаты не влияет. +6. **Смок поля в симуляторе — ограничение среды.** Симулятор 4.2.2 не + хостит поля данных в симулируемой активности (поле нельзя + «открыть» как приложение), поэтому интерактивные проверки спеки 03 + §5 (проводка пульса по зонам, Lap, пауза, вибрации глазами) — + приёмка владельца на реальном Fenix 6X (прецедент m12: «на + устройстве — приёмка владельца»). Поведенческое ядро этих проверок + закрыто юнит-тестами: пороги всех 4 зон (границы 89/90, 125/126, + 152/153 при max 180), прочерк без пульса (ZONE_NONE), обнуление по + Lap + отсчёт отдыха + однократность сигнала, гистерезис красной + 60 с, целочисленный mmss. Отрисовка — параметрическая, собирается + под 280×280/260×260/240×240 без правок кода. +7. **Мультитаргет-инвариант подтверждён:** fenix6 (260×260) и fenix6s + (240×240) — release и тестовые prg собираются только заменой + `` в манифесте (правки кода — нулевые; проверочные + манифесты во временной папке, в репо не попадают). Симулятор 9.2 в + контуре не используется (отвергает подписи нашего пака); если + понадобится — отдельное исследование (sdk-settings.json симулятора). +8. **Monkey C-ловушки, пойманные тестами:** `==` для строк — по ссылке + (литерал ≠ собранная строка; сверять `Lang.String.equals`); + `число + строка` не склеивается — конкатенация только со строки + (`"" + x + …`); дефолтная джунгла компилирует `.\**.mc` рекурсивно + — джунглы проекта обязаны задавать `base.sourcePath` явно (иначе + test-source попадает в release). +9. **Сборка/прогон (команды).** Сборка: `monkeyc.bat -d fenix6xpro -w + -f monkey.jungle -y <ключ> -o bin\FenixDataField.prg` (тест — + `-f monkey-test.jungle … FenixDataField-test.prg`; JAVA_HOME = + JDK 21). Симулятор: `bin\connectiq.bat` (запуск вне песочницы), + затем `monkeydo.bat fenix6xpro` (без /t) — юнит-вывод в + консоль; лог прошивки — + `%TEMP%\com.garmin.connectiq\GARMIN\APPS\LOGS\CIQ_LOG.YML`. + +## Sideload на Fenix 6X (приёмка владельца) + +1. На часах включить режим разработчика: **Settings → System → + Developer Mode → On** (перезагрузка по запросу). +2. Подключить часы по USB (режим MTP), открыть диск **GARMIN**. +3. Скопировать `FenixDataField.prg` в **`GARMIN\APPS\DataField\`** + (если папки нет — создать; вариант совместимости — + `GARMIN\APPS\WatchApp\`). +4. Отсоединить USB. Профиль **Strength (Силовая)** → настройки + тренировки → Data Screens → страница → Data Field 1 → **Феникс** + (Phoenix). +5. Проверки: пульс крупно + плашка зоны с подписью; Lap (кнопка + BACK/LAP на 6X) — строка «ОТДЫХ 2:00» тикает вниз, по нулю — + короткая вибрация; пульс > 85 % maxBpm (умолчание Танака 45 лет = + 176) — двойная вибрация не чаще раза в минуту; пауза активности — + отсчёты стоят; без нагрудного датчика/оптического пульса — «—», + поле не падает. Настройки `restSec`/`hrMaxBpm`/`age` в dev-сборке — + зашитые умолчания (решение 3 гейта 1.3). +6. Удаление: часы → Settings → System → Storage → Connect IQ / + `GARMIN\APPS\DataField\FenixDataField.prg` вручную. diff --git a/3-app/5-watch/FenixDataField/.gitignore b/3-app/5-watch/FenixDataField/.gitignore new file mode 100644 index 0000000..95e3757 --- /dev/null +++ b/3-app/5-watch/FenixDataField/.gitignore @@ -0,0 +1,2 @@ +bin/ +*.prg diff --git a/3-app/5-watch/FenixDataField/manifest.xml b/3-app/5-watch/FenixDataField/manifest.xml new file mode 100644 index 0000000..128d200 --- /dev/null +++ b/3-app/5-watch/FenixDataField/manifest.xml @@ -0,0 +1,13 @@ + + + + + + + + eng + rus + + + + diff --git a/3-app/5-watch/FenixDataField/monkey-test.jungle b/3-app/5-watch/FenixDataField/monkey-test.jungle new file mode 100644 index 0000000..7b8444c --- /dev/null +++ b/3-app/5-watch/FenixDataField/monkey-test.jungle @@ -0,0 +1,2 @@ +project.manifest = test-manifest.xml +base.sourcePath = source;test-source diff --git a/3-app/5-watch/FenixDataField/monkey.jungle b/3-app/5-watch/FenixDataField/monkey.jungle new file mode 100644 index 0000000..6483800 --- /dev/null +++ b/3-app/5-watch/FenixDataField/monkey.jungle @@ -0,0 +1,2 @@ +project.manifest = manifest.xml +base.sourcePath = source diff --git a/3-app/5-watch/FenixDataField/resources-rus/strings.xml b/3-app/5-watch/FenixDataField/resources-rus/strings.xml new file mode 100644 index 0000000..bc004ba --- /dev/null +++ b/3-app/5-watch/FenixDataField/resources-rus/strings.xml @@ -0,0 +1,20 @@ + + + Феникс + восстановление + лёгкая + средняя + высокая + нет данных + уд/мин + ПОДХОД + ОТДЫХ + ср + Отдых, секунд + Отдых: 30–600 с + Макс. ЧСС (0 = формула Танаки по возрасту) + Макс. ЧСС: 0–250 + Возраст (для формулы Танаки) + Возраст: 14–100 + + diff --git a/3-app/5-watch/FenixDataField/resources/drawables/drawables.xml b/3-app/5-watch/FenixDataField/resources/drawables/drawables.xml new file mode 100644 index 0000000..6785957 --- /dev/null +++ b/3-app/5-watch/FenixDataField/resources/drawables/drawables.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/3-app/5-watch/FenixDataField/resources/drawables/launcher_icon.png b/3-app/5-watch/FenixDataField/resources/drawables/launcher_icon.png new file mode 100644 index 0000000..bba94a6 Binary files /dev/null and b/3-app/5-watch/FenixDataField/resources/drawables/launcher_icon.png differ diff --git a/3-app/5-watch/FenixDataField/resources/settings/settings.xml b/3-app/5-watch/FenixDataField/resources/settings/settings.xml new file mode 100644 index 0000000..cbaeb59 --- /dev/null +++ b/3-app/5-watch/FenixDataField/resources/settings/settings.xml @@ -0,0 +1,18 @@ + + + 120 + 0 + 45 + + + + + + + + + + + + + diff --git a/3-app/5-watch/FenixDataField/resources/strings.xml b/3-app/5-watch/FenixDataField/resources/strings.xml new file mode 100644 index 0000000..4e87275 --- /dev/null +++ b/3-app/5-watch/FenixDataField/resources/strings.xml @@ -0,0 +1,20 @@ + + + Phoenix + recovery + easy + moderate + high + no data + bpm + SET + REST + avg + Rest timer, seconds + Rest must be 30-600 s + Max HR, bpm (0 = Tanaka formula by age) + Max HR must be 0-250 + Age (for the Tanaka formula) + Age must be 14-100 + + diff --git a/3-app/5-watch/FenixDataField/source/FenixDataField.mc b/3-app/5-watch/FenixDataField/source/FenixDataField.mc new file mode 100644 index 0000000..e8be1ba --- /dev/null +++ b/3-app/5-watch/FenixDataField/source/FenixDataField.mc @@ -0,0 +1,191 @@ +// Поле данных «Феникс» для профиля «Силовая» (Ф-2, m13). +// Экран: текущий пульс (крупно) + плашка зоны цветом с подписью, строка +// «ПОДХОД мм:сс - ср N» / «ОТДЫХ мм:сс». Lap — граница подхода и старт +// автономного отдыха (errata Ф-2.3/Ф-2.4). Кнопки не перехватываются, +// запись .fit ведёт платформа Garmin (Ф-2.5–Ф-2.7). +import Toybox.Activity; +import Toybox.Graphics; +import Toybox.WatchUi; + +class FenixDataField extends WatchUi.DataField { + var _settings; + var _zones; + var _set; + var _rest; + var _vibe; + var _fmt; + + var _hr; // кэш пульса (Number или null) + var _maxBpm10; // макс. ЧСС ×10 + var _zone; // текущая зона (идентификатор ZoneCalc) + + function initialize() { + DataField.initialize(); + _settings = new FieldSettings(); + _zones = new ZoneCalc(); + _set = new SetTracker(); + _rest = new RestTimer(); + _vibe = new Vibe(); + _fmt = new Format(); + _hr = null; + _maxBpm10 = _zones.maxBpm10For(_settings.hrMaxBpm, _settings.age); + _zone = _zones.ZONE_NONE; + } + + function timerNow() { + var info = Activity.getActivityInfo(); + if (info != null && info.timerTime != null) { + return info.timerTime; + } + return 0; + } + + // Lap (штатно — конец подхода): метрики обнуляются, отдых стартует + function onTimerLap() { + var t = timerNow(); + _set.onLap(t); + _rest.start(t, _settings.restSec); + } + + function onTimerStart() { + resetState(); + } + + function onTimerReset() { + resetState(); + } + + function resetState() { + _set.onLap(0); + _rest.initialize(); + } + + // Раз в секунду: пульс → метрики, зоны, сигналы вибраций + function compute(info) { + _hr = info.currentHeartRate; + var t = info.timerTime; + if (t == null) { + return; + } + _set.onSample(_hr); + _zone = _zones.zoneFor(_hr, _maxBpm10); + if (_rest.onEnd(t)) { + _vibe.vibrateRestEnd(); + } + _vibe.onZone(_zone == _zones.ZONE_RED, t); + } + + function colorFor(zoneId) { + if (zoneId == _zones.ZONE_BLUE) { + return Graphics.COLOR_BLUE; + } + if (zoneId == _zones.ZONE_GREEN) { + return Graphics.COLOR_GREEN; + } + if (zoneId == _zones.ZONE_YELLOW) { + return Graphics.COLOR_YELLOW; + } + if (zoneId == _zones.ZONE_RED) { + return Graphics.COLOR_RED; + } + return Graphics.COLOR_DK_GRAY; + } + + function labelFor(zoneId) { + if (zoneId == _zones.ZONE_BLUE) { + return WatchUi.loadResource(Rez.Strings.ZoneBlue); + } + if (zoneId == _zones.ZONE_GREEN) { + return WatchUi.loadResource(Rez.Strings.ZoneGreen); + } + if (zoneId == _zones.ZONE_YELLOW) { + return WatchUi.loadResource(Rez.Strings.ZoneYellow); + } + if (zoneId == _zones.ZONE_RED) { + return WatchUi.loadResource(Rez.Strings.ZoneRed); + } + return WatchUi.loadResource(Rez.Strings.ZoneNone); + } + + // Параметрическая отрисовка по размерам области (dc): ни одной + // фиксированной координаты — один код на всё семейство экранов (Ф-2.8) + function onUpdate(dc) { + var w = dc.getWidth(); + var h = dc.getHeight(); + + dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_BLACK); + dc.clear(); + + // 1) плашка зоны сверху: цвет зоны + подпись текстом (цвет — + // не единственный носитель, ui-ux) + var tiny = Graphics.FONT_TINY; + var zoneLabel = labelFor(_zone); + var tw = dc.getTextWidthInPixels(zoneLabel, tiny); + var th = dc.getFontHeight(tiny); + var pad = 4; + var plateW = tw + pad * 2; + var plateH = th + pad; + var plateX = (w - plateW) / 2; + var plateY = 2; + if (_zone == _zones.ZONE_NONE) { + dc.setColor(Graphics.COLOR_DK_GRAY, Graphics.COLOR_DK_GRAY); + dc.fillRectangle(plateX, plateY, plateW, plateH); + dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_DK_GRAY); + } else { + var zc = colorFor(_zone); + dc.setColor(zc, zc); + dc.fillRectangle(plateX, plateY, plateW, plateH); + dc.setColor(Graphics.COLOR_BLACK, zc); + } + dc.drawText(plateX + pad, plateY + 1, tiny, zoneLabel, Graphics.TEXT_JUSTIFY_LEFT); + + // 2) пульс — крупное число (адаптивный шрифт: THAI_HOT, если не + // влезает — MEDIUM; поле может делить страницу с другими полями) + var bigFont = Graphics.FONT_NUMBER_THAI_HOT; + var bigH = dc.getFontHeight(bigFont); + if (bigH > h * 5 / 10) { + bigFont = Graphics.FONT_NUMBER_MEDIUM; + bigH = dc.getFontHeight(bigFont); + } + var hrText; + if (_hr == null || _hr <= 0) { + hrText = "-"; + } else { + hrText = "" + _hr; + } + dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_BLACK); + var hw = dc.getTextWidthInPixels(hrText, bigFont); + var hrY = plateY + plateH + (h - plateH - plateY - bigH - th - 8) / 2; + if (hrY < plateY + plateH + 2) { + hrY = plateY + plateH + 2; + } + dc.drawText((w - hw) / 2, hrY, bigFont, hrText, Graphics.TEXT_JUSTIFY_LEFT); + + // 3) подпись единиц под пульсом + var unit = WatchUi.loadResource(Rez.Strings.Unit); + var uw = dc.getTextWidthInPixels(unit, tiny); + dc.drawText((w - uw) / 2, hrY + bigH + 1, tiny, unit, Graphics.TEXT_JUSTIFY_LEFT); + + // 4) строка состояния: ОТДЫХ (остаток) или ПОДХОД (время с границы + // Lap - средний пульс интервала) + var line; + var t = timerNow(); + var small = Graphics.FONT_SMALL; + var lh = dc.getFontHeight(small); + if (_rest.isResting(t)) { + line = WatchUi.loadResource(Rez.Strings.Rest) + " " + _fmt.mmss(_rest.remainingMs(t)); + } else { + var avg = _set.avgBpm(); + var avgText; + if (avg == null) { + avgText = "-"; + } else { + avgText = "" + avg; + } + line = WatchUi.loadResource(Rez.Strings.Set) + " " + _fmt.mmss(_set.elapsedMs(t)) + + " - " + WatchUi.loadResource(Rez.Strings.Avg) + " " + avgText; + } + var lw = dc.getTextWidthInPixels(line, small); + dc.drawText((w - lw) / 2, h - lh - 2, small, line, Graphics.TEXT_JUSTIFY_LEFT); + } +} diff --git a/3-app/5-watch/FenixDataField/source/FieldSettings.mc b/3-app/5-watch/FenixDataField/source/FieldSettings.mc new file mode 100644 index 0000000..4558944 --- /dev/null +++ b/3-app/5-watch/FenixDataField/source/FieldSettings.mc @@ -0,0 +1,39 @@ +// Настройки поля (properties, правка — Garmin Connect Mobile/Express): +// restSec 30–600 (умолчание 120 = Rest.DefaultSec телефона), +// hrMaxBpm 0–250 (0 = формула Танаки от age), age 14–100 (45). +// Клампы защищают от значений вне диапазона (правка без редактора). +// getApp() может быть null вне контекста приложения (тесты) — 🛡 дефолты. +import Toybox.Application; +import Toybox.Lang; + +class FieldSettings { + var restSec; + var hrMaxBpm; + var age; + + function initialize() { + restSec = clamp(readProp("restSec"), 30, 600, 120); + hrMaxBpm = clamp(readProp("hrMaxBpm"), 0, 250, 0); + age = clamp(readProp("age"), 14, 100, 45); + } + + // Значение properties или null (нет приложения/свойства/ошибка) + function readProp(name) { + try { + var app = Application.getApp(); + if (app != null) { + return app.getProperty(name); + } + } catch (e) { + } + return null; + } + + // null / не число / вне диапазона → умолчание + function clamp(v, lo, hi, def) { + if (v instanceof Lang.Number && v >= lo && v <= hi) { + return v; + } + return def; + } +} diff --git a/3-app/5-watch/FenixDataField/source/Format.mc b/3-app/5-watch/FenixDataField/source/Format.mc new file mode 100644 index 0000000..c5e06e3 --- /dev/null +++ b/3-app/5-watch/FenixDataField/source/Format.mc @@ -0,0 +1,28 @@ +// Целочисленное форматирование — без Float (дробное деление Monkey C +// даёт Float, который рисуется с десятичной частью на MIP-экране). +class Format { + // q = floor(a / b); a ≥ 0, b > 0 + function div(a, b) { + var q = 0; + while (a >= b) { + a -= b; + q += 1; + } + return q; + } + + // «m:ss» / «mm:ss» из миллисекунд, округление вниз до секунд. + // Конкатенация — со строки: «число + строка» в Monkey C не склеивается + function mmss(ms) { + if (ms == null || ms < 0) { + ms = 0; + } + var sec = div(ms, 1000); + var min = div(sec, 60); + var s = sec - min * 60; + if (s < 10) { + return "" + min + ":0" + s; + } + return "" + min + ":" + s; + } +} diff --git a/3-app/5-watch/FenixDataField/source/RestTimer.mc b/3-app/5-watch/FenixDataField/source/RestTimer.mc new file mode 100644 index 0000000..fd9d0f8 --- /dev/null +++ b/3-app/5-watch/FenixDataField/source/RestTimer.mc @@ -0,0 +1,43 @@ +// Автономный таймер отдыха (Ф-2.4 errata): старт по Lap, длительность — +// настройка restSec (умолчание 120 = Rest.DefaultSec телефона), конец — +// однократная лёгкая вибрация. Часы — timerTime: при паузе активности +// остаток замирает сам, отдельной логики паузы нет. +class RestTimer { + var active; + var restEndMs; + var fired; + + function initialize() { + active = false; + restEndMs = 0; + fired = false; + } + + function start(timerTimeMs, restSec) { + active = true; + restEndMs = timerTimeMs + restSec * 1000; + fired = false; + } + + // Остаток отдыха, мс (0 если таймер не активен) + function remainingMs(timerTimeMs) { + if (active) { + return restEndMs - timerTimeMs; + } + return 0; + } + + // Идёт ли отдых (остаток > 0) — на экране строка «ОТДЫХ mm:ss» + function isResting(timerTimeMs) { + return active && (restEndMs - timerTimeMs) > 0; + } + + // true ровно один раз при переходе остатка через ноль — сигнал вибрации + function onEnd(timerTimeMs) { + if (active && !fired && (restEndMs - timerTimeMs) <= 0) { + fired = true; + return true; + } + return false; + } +} diff --git a/3-app/5-watch/FenixDataField/source/SetTracker.mc b/3-app/5-watch/FenixDataField/source/SetTracker.mc new file mode 100644 index 0000000..151b2f2 --- /dev/null +++ b/3-app/5-watch/FenixDataField/source/SetTracker.mc @@ -0,0 +1,54 @@ +// Метрики подхода между нажатиями Lap (errata Ф-2.3): +// «время подхода» = время с последнего Lap по timerTime (включает предшествующий +// отдых — errata Ф-2.1), «средний пульс подхода» = среднее сэмплов за интервал. +// Часы ядра — Activity.Info.timerTime (при паузе активности стоит сам). +class SetTracker { + var lapAtMs; // timerTime последнего Lap (мс) + var sumBpm; + var nBpm; + + function initialize() { + lapAtMs = 0; + sumBpm = 0; + nBpm = 0; + } + + // Lap = граница подхода: метрики обнуляются + function onLap(timerTimeMs) { + lapAtMs = timerTimeMs; + sumBpm = 0; + nBpm = 0; + } + + // null-пульс (датчик не готов) в среднее не попадает + function onSample(bpm) { + if (bpm != null && bpm > 0) { + sumBpm += bpm; + nBpm += 1; + } + } + + // Время с границы подхода, мс + function elapsedMs(timerTimeMs) { + return timerTimeMs - lapAtMs; + } + + // Средний пульс интервала (округление к ближайшему, целочисленно — без + // Float), null пока нет сэмплов + function avgBpm() { + if (nBpm > 0) { + var num = sumBpm * 2; + var den = nBpm * 2; + var q = 0; + while (num >= den) { + num -= den; + q += 1; + } + if (num * 2 >= den) { + q += 1; + } + return q; + } + return null; + } +} diff --git a/3-app/5-watch/FenixDataField/source/Vibe.mc b/3-app/5-watch/FenixDataField/source/Vibe.mc new file mode 100644 index 0000000..7244372 --- /dev/null +++ b/3-app/5-watch/FenixDataField/source/Vibe.mc @@ -0,0 +1,55 @@ +// Вибрации (Ф-2.4): конец отдыха — однократная лёгкая; красная зона — +// двойная с гистерезисом (повтор не раньше 60 с, сброс при выходе из +// красной). Паттерны различаются (ui-ux: отдых/предупреждение). +import Toybox.Attention; + +class Vibe { + var RED_REPEAT_MS = 60000; + + var lastRedMs; + var inRed; + + function initialize() { + lastRedMs = null; + inRed = false; + } + + // Вызывается раз в секунду из compute; isRed — пульс в красной зоне. + // Возвращает true, если двойная вибрация запущена. + function onZone(isRed, timerTimeMs) { + if (isRed) { + if (!inRed) { + inRed = true; + lastRedMs = null; + } + if (lastRedMs == null || (timerTimeMs - lastRedMs) >= RED_REPEAT_MS) { + vibrateDouble(); + lastRedMs = timerTimeMs; + return true; + } + } else { + inRed = false; + } + return false; + } + + function vibrateRestEnd() { + safeVibrate([new Attention.VibeProfile(25, 300)]); + } + + function vibrateDouble() { + safeVibrate([ + new Attention.VibeProfile(75, 200), + new Attention.VibeProfile(0, 150), + new Attention.VibeProfile(75, 200) + ]); + } + + // Вибрация недоступна на модели — молча пропускается (Ф-2.8) + function safeVibrate(profiles) { + try { + Attention.vibrate(profiles); + } catch (e) { + } + } +} diff --git a/3-app/5-watch/FenixDataField/source/ZoneCalc.mc b/3-app/5-watch/FenixDataField/source/ZoneCalc.mc new file mode 100644 index 0000000..db89d49 --- /dev/null +++ b/3-app/5-watch/FenixDataField/source/ZoneCalc.mc @@ -0,0 +1,41 @@ +// Зоны пульса: 50/70/85 % от макс. ЧСС (Ф-2.2). +// Макс. ЧСС хранится в масштабе ×10 (maxBpm10) — весь расчёт целочисленный, +// без Float; формула Танаки 208 − 0,7×возраст тоже считается в ×10. +class ZoneCalc { + // Идентификаторы зон (цвет на экране дублируется подписью) + var ZONE_NONE = 0; // нет данных — прочерк + var ZONE_BLUE = 1; // < 50 % — восстановление + var ZONE_GREEN = 2; // 50–70 % — лёгкая + var ZONE_YELLOW = 3; // 70–85 % — средняя + var ZONE_RED = 4; // ≥ 85 % — высокая + + // Макс. ЧСС ×10: ручное значение (>0) приоритетно, иначе Танака от возраста. + // Возраст вне 14–100 — умолчание 45. + function maxBpm10For(hrMaxBpm, age) { + if (hrMaxBpm != null && hrMaxBpm > 0) { + return hrMaxBpm * 10; + } + if (age == null || age < 14 || age > 100) { + age = 45; + } + return 2080 - 7 * age; + } + + // Зона по пульсу. Пороги в целых числах (масштаб ×10): + // 50 % → bpm×20 < maxBpm10; 70 % → bpm×100 < maxBpm10×7; 85 % → bpm×200 < maxBpm10×17 + function zoneFor(bpm, maxBpm10) { + if (bpm == null || bpm <= 0 || maxBpm10 == null || maxBpm10 <= 0) { + return ZONE_NONE; + } + if (bpm * 20 < maxBpm10) { + return ZONE_BLUE; + } + if (bpm * 100 < maxBpm10 * 7) { + return ZONE_GREEN; + } + if (bpm * 200 < maxBpm10 * 17) { + return ZONE_YELLOW; + } + return ZONE_RED; + } +} diff --git a/3-app/5-watch/FenixDataField/test-manifest.xml b/3-app/5-watch/FenixDataField/test-manifest.xml new file mode 100644 index 0000000..35e6ead --- /dev/null +++ b/3-app/5-watch/FenixDataField/test-manifest.xml @@ -0,0 +1,13 @@ + + + + + + + + eng + rus + + + + diff --git a/3-app/5-watch/FenixDataField/test-source/CoreChecks.mc b/3-app/5-watch/FenixDataField/test-source/CoreChecks.mc new file mode 100644 index 0000000..5525599 --- /dev/null +++ b/3-app/5-watch/FenixDataField/test-source/CoreChecks.mc @@ -0,0 +1,49 @@ +// Мини-раннер юнит-тестов ядра. Штатный (:test)-harness (Toybox.Test + +// monkeydo /t) не стартует в связке «симулятор 4.2.2 + девайс-пак из +// docker-образа» — Unexpected Type Error на старте ЛЮБОГО тестового prg +// (проверено на минимальном проекте из 15 строк), поэтому тесты гоняет +// собственный раннер: те же проверки (спека 02), вывод println → консоль +// симулятора (monkeydo). Файлы в test-source/ — в release-сборку поля +// (monkey.jungle → source/) не попадают. +import Toybox.Lang; +import Toybox.System; + +class CoreChecks { + var failed; + var passed; + + function initialize() { + failed = 0; + passed = 0; + } + + function eq(a, b, msg) { + // «==» для строк — сравнение по ссылке: литерал и собранная + // строка не равны, строки сверяем через equals + var okEq = a == b; + if (!okEq && a instanceof Lang.String && b instanceof Lang.String) { + okEq = a.equals(b); + } + if (okEq) { + passed += 1; + } else { + failed += 1; + System.println("FAIL: " + msg); + } + } + + // Отдельно для null: eq печатал бы значения (concat с null небезопасен) + function isNull(v, msg) { + if (v == null) { + passed += 1; + } else { + failed += 1; + System.println("FAIL: " + msg); + } + } + + function summary() { + System.println("RESULT: passed=" + passed + " failed=" + failed); + return failed; + } +} diff --git a/3-app/5-watch/FenixDataField/test-source/CoreTests.mc b/3-app/5-watch/FenixDataField/test-source/CoreTests.mc new file mode 100644 index 0000000..80962b6 --- /dev/null +++ b/3-app/5-watch/FenixDataField/test-source/CoreTests.mc @@ -0,0 +1,139 @@ +import Toybox.System; +// Юнит-тесты ядра (спека api_specs/02-core.md): зоны, метрики подхода, +// таймер отдыха, гистерезис вибрации, клампы настроек, форматирование. +// Запуск — TestEntry (watch-app) в симуляторе; проверки — CoreChecks. +// Поле данных нельзя стартовать как приложение, поэтому тестовая сборка +// — отдельный манифест test-manifest.xml c watch-app-входом. + +// ---------- ZoneCalc ---------- + +function zoneTanakaTest(c) { + var z = new ZoneCalc(); + // возраст 45: (2080 − 315)/10 = 176,5 — используется точное значение ×10 + c.eq(z.maxBpm10For(0, 45), 1765, "Tanaka age 45 -> 1765 (x10)"); + c.eq(z.maxBpm10For(180, 45), 1800, "manual max has priority"); + c.eq(z.maxBpm10For(0, 5), 1765, "age out of range -> default 45"); + c.eq(z.maxBpm10For(null, null), 1765, "nulls -> default 45"); +} + +function zoneBoundariesTest(c) { + var z = new ZoneCalc(); + // maxBpm 180 (maxBpm10 1800): 50 % = 90, 70 % = 126, 85 % = 153 + c.eq(z.zoneFor(89, 1800), z.ZONE_BLUE, "89 -> blue"); + c.eq(z.zoneFor(90, 1800), z.ZONE_GREEN, "90 -> green"); + c.eq(z.zoneFor(125, 1800), z.ZONE_GREEN, "125 -> green"); + c.eq(z.zoneFor(126, 1800), z.ZONE_YELLOW, "126 -> yellow"); + c.eq(z.zoneFor(152, 1800), z.ZONE_YELLOW, "152 -> yellow"); + c.eq(z.zoneFor(153, 1800), z.ZONE_RED, "153 -> red"); + c.eq(z.zoneFor(null, 1800), z.ZONE_NONE, "null hr -> none"); + c.eq(z.zoneFor(0, 1800), z.ZONE_NONE, "zero hr -> none"); +} + +// ---------- SetTracker ---------- + +function setAvgRoundingTest(c) { + var s = new SetTracker(); + s.onSample(100); + s.onSample(101); + s.onSample(101); + c.eq(s.avgBpm(), 101, "100+101+101 -> 101 (round nearest)"); +} + +function setNullSkippedTest(c) { + var s = new SetTracker(); + s.onSample(100); + s.onSample(null); + s.onSample(102); + c.eq(s.avgBpm(), 101, "null samples skipped -> 101"); +} + +function setLapResetsTest(c) { + var s = new SetTracker(); + s.onSample(150); + c.eq(s.avgBpm(), 150, "before lap avg 150"); + s.onLap(60000); + c.isNull(s.avgBpm(), "lap resets samples"); + s.onSample(120); + c.eq(s.avgBpm(), 120, "after lap only new sample counted"); +} + +function setElapsedTest(c) { + var s = new SetTracker(); + s.onLap(60000); + c.eq(s.elapsedMs(75500), 15500, "elapsed from lap border"); +} + +// ---------- RestTimer ---------- + +function restCountdownTest(c) { + var r = new RestTimer(); + c.eq(r.isResting(60000), false, "before start not active -> false"); + r.start(60000, 120); + c.eq(r.remainingMs(60000), 120000, "just started: full 120 s"); + c.eq(r.remainingMs(120000), 60000, "half-way: 60 s left"); + c.eq(r.remainingMs(180000), 0, "at end: 0"); + c.eq(r.isResting(179999), true, "resting while remaining > 0"); + c.eq(r.isResting(180000), false, "not resting at 0"); +} + +function restEndFiresOnceTest(c) { + var r = new RestTimer(); + r.start(0, 120); + c.eq(r.onEnd(119000), false, "not ended yet"); + c.eq(r.onEnd(120000), true, "fires at end"); + c.eq(r.onEnd(121000), false, "fires only once"); + r.start(130000, 60); + c.eq(r.onEnd(190000), true, "new start re-arms"); +} + +// ---------- Vibe (гистерезис красной зоны) ---------- + +function vibeRedHysteresisTest(c) { + var v = new Vibe(); + c.eq(v.onZone(true, 0), true, "enter red -> vibrate"); + c.eq(v.onZone(true, 30000), false, "still red <60 s -> no"); + c.eq(v.onZone(true, 59999), false, "still red <60 s -> no"); + c.eq(v.onZone(true, 61000), true, "still red >=60 s -> vibrate"); +} + +function vibeRedResetTest(c) { + var v = new Vibe(); + c.eq(v.onZone(true, 0), true, "enter red -> vibrate"); + c.eq(v.onZone(false, 10000), false, "leave red -> no vibrate"); + c.eq(v.onZone(true, 20000), true, "re-enter red -> vibrate (reset)"); +} + +// ---------- Format ---------- + +function mmssTest(c) { + var f = new Format(); + c.eq(f.mmss(0), "0:00", "0 -> 0:00"); + c.eq(f.mmss(59999), "0:59", "59999 -> 0:59"); + c.eq(f.mmss(60000), "1:00", "60000 -> 1:00"); + c.eq(f.mmss(65000), "1:05", "65000 -> 1:05"); + c.eq(f.mmss(600000), "10:00", "600000 -> 10:00"); + c.eq(f.mmss(null), "0:00", "null -> 0:00"); + c.eq(f.mmss(-5), "0:00", "negative -> 0:00"); +} + +function divTest(c) { + var f = new Format(); + c.eq(f.div(5, 2), 2, "5/2 floor = 2"); + c.eq(f.div(1, 2), 0, "1/2 floor = 0"); + c.eq(f.div(600000, 1000), 600, "600000/1000 = 600"); +} + +// ---------- FieldSettings (клампы) ---------- + +function clampTest(c) { + var s = new FieldSettings(); + c.eq(s.clamp(10, 30, 600, 120), 120, "rest below min -> default"); + c.eq(s.clamp(600, 30, 600, 120), 600, "rest max ok"); + c.eq(s.clamp(30, 30, 600, 120), 30, "rest min ok"); + c.eq(s.clamp(null, 30, 600, 120), 120, "null -> default"); + c.eq(s.clamp("120", 30, 600, 120), 120, "string -> default"); + c.eq(s.clamp(-5, 0, 250, 0), 0, "hr below min -> default 0"); + c.eq(s.clamp(250, 0, 250, 0), 250, "hr max ok"); + c.eq(s.clamp(200, 14, 100, 45), 45, "age above max -> default"); + c.eq(s.clamp(14, 14, 100, 45), 14, "age min ok"); +} diff --git a/3-app/5-watch/FenixDataField/test-source/TestEntry.mc b/3-app/5-watch/FenixDataField/test-source/TestEntry.mc new file mode 100644 index 0000000..0afa417 --- /dev/null +++ b/3-app/5-watch/FenixDataField/test-source/TestEntry.mc @@ -0,0 +1,29 @@ +// Вход тестовой сборки (watch-app): поле данных нельзя запустить как +// приложение, поэтому тесты стартует watch-app-вход (test-manifest.xml). +// onStart прогоняет все тесты ядра и печатает итог в консоль симулятора +// (monkeydo без /t). В release-сборку не попадает (test-source/). +import Toybox.Application; + +class TestEntry extends Application.AppBase { + function initialize() { + AppBase.initialize(); + } + + function onStart(state) { + var c = new CoreChecks(); + zoneTanakaTest(c); + zoneBoundariesTest(c); + setAvgRoundingTest(c); + setNullSkippedTest(c); + setLapResetsTest(c); + setElapsedTest(c); + restCountdownTest(c); + restEndFiresOnceTest(c); + vibeRedHysteresisTest(c); + vibeRedResetTest(c); + mmssTest(c); + divTest(c); + clampTest(c); + c.summary(); + } +}