m13-fenix-data-field (итерация 5, последний модуль v1): поле данных Connect IQ «Феникс» для профиля «Силовая» Fenix 6X — errata Ф-2.3/Ф-2.1 (утв. гейт 1.3): события Strength Set в CIQ API нет ни в одной версии → подход = Lap→Lap (onTimerLap), «время подхода» = с последнего Lap; ядро Monkey C (source/): ZoneCalc (зоны 50/70/85 % целочисленно в масштабе ×10, Танака 2080−7×age / ручной hrMaxBpm), SetTracker (среднее интервала с null-пропуском, округление к ближайшему без Float), RestTimer (старт по Lap, окончание — ровно один сигнал), Vibe (конец отдыха лёгкая / красная двойная, гистерезис 60 с, safeVibrate Ф-2.8), Format (mmss целочисленно), FieldSettings (properties restSec 30–600/hrMaxBpm 0–250/age 14–100 с клампами, getApp null-safe); экран — параметрический (без фиксированных координат: плашка зоны цвет+подпись, пульс FONT_NUMBER_THAI_HOT→MEDIUM адаптивно, строка ПОДХОД/ОТДЫХ mm:ss·ср N, прочерки), ru/en ресурсы, иконка 40×40; тесты: штатный harness monkeydo /t несовместим с девайс-паком docker в симуляторе (Unexpected Type Error у любого тестового prg, проверено минимальным проектом) → собственный раннер test-source/ (watch-app TestEntry + CoreChecks, в release не попадает — base.sourcePath задан явно): 54/54 в симуляторе fenix6xpro, раннер поймал 3 бага ядра (шкала порогов зон, слагаемое округления avgBpm, конкатенация mmss) — исправлены; тулчейн: SDK 4.2.2 (Wayback) + девайс-паки из docker 4.1.7 → %APPDATA% (таргет fenix6xpro), developer-ключ ОБЯЗАН быть RSA-4096 PKCS#8 DER (2048 молча отвергался прошивкой — Signature check failed, подтверждено сэмплем Garmin); мультитаргет-инвариант: fenix6 260×260 + fenix6s 240×240 собираются только заменой iq:product, правок кода 0; симулятор 4.2.2 не хостит поля данных → интерактивный смок (спека 03 §5) и sideload (Developer Mode → USB → GARMIN\APPS\DataField) — приёмка владельца, инструкция в readme; Monkey C-ловушки зафиксированы (строки == по ссылке, число+строка не склеивается, dc.setFont нет на API 3.x — justification 5-м аргументом); errata НД внесена после гейта 1.3: requirements Ф-2.1/Ф-2.3, tech-req 7п.1; plan.md m13 ✅ — все 13 модулей v1 завершены
This commit is contained in:
parent
d4867acefd
commit
2f8c6095a7
|
|
@ -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) |
|
| 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; запуск на устройстве — приёмка владельца) |
|
| 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 вручную) |
|
| 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/разрешения — приёмка владельца; НЕ закоммичен) |
|
| 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 | ⬜ |
|
| 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) выполняются в
|
Прототипы чек-листа шага 7 (tech-requirements разд. 7) выполняются в
|
||||||
своих итерациях; ОВ-3 (companion-канал) — прототип в итерации 1 параллельно
|
своих итерациях; ОВ-3 (companion-канал) — прототип в итерации 1 параллельно
|
||||||
|
|
|
||||||
|
|
@ -178,14 +178,19 @@ Garmin штатно — Ф-2.5, Ф-2.6). Код поля данных перен
|
||||||
|
|
||||||
1. Метрики на экране: текущий пульс (шрифт 40–48 pt), средний пульс
|
1. Метрики на экране: текущий пульс (шрифт 40–48 pt), средний пульс
|
||||||
текущего подхода (от начала подхода до текущего момента), время
|
текущего подхода (от начала подхода до текущего момента), время
|
||||||
текущего подхода (под нагрузкой).
|
с последнего Lap (границы подхода — Ф-2.3; включает предшествующий
|
||||||
|
отдых — точное время «под нагрузкой» не измеряется: детекция
|
||||||
|
повторений недоступна Connect IQ API, errata 2026-10-19).
|
||||||
2. Зона пульса — цвет ободка/фона: синий < 50 %, зелёный 50–70 %,
|
2. Зона пульса — цвет ободка/фона: синий < 50 %, зелёный 50–70 %,
|
||||||
жёлтый 70–85 %, красный > 85 % от `Hr.MaxBpm`; границы — из настроек
|
жёлтый 70–85 %, красный > 85 % от `Hr.MaxBpm`; границы — из настроек
|
||||||
поля данных (Connect IQ settings; автоматическая передача с телефона
|
поля данных (Connect IQ settings; автоматическая передача с телефона
|
||||||
— открытый вопрос 3).
|
— открытый вопрос 3).
|
||||||
3. Границы подхода: поле считает подход открытым от штатного события
|
3. Границы подхода — между нажатиями кнопки Lap (штатная семантика
|
||||||
нового подхода (Strength Set) до события Lap; метрики подхода
|
«Силовой»: Lap — конец подхода, метки Lap попадают в .fit, КУ-2):
|
||||||
обнуляются на границе.
|
событие Strength Set в Connect IQ API отсутствует (errata
|
||||||
|
2026-10-19 — класса повторений нет ни в одной версии платформы,
|
||||||
|
проверено по актуальному API); метрики подхода считаются за
|
||||||
|
интервал Lap→Lap и обнуляются на Lap.
|
||||||
4. Вибрация: однократная лёгкая — конец автономного таймера отдыха
|
4. Вибрация: однократная лёгкая — конец автономного таймера отдыха
|
||||||
поля (длительность — настройка поля в Garmin Connect Mobile,
|
поля (длительность — настройка поля в Garmin Connect Mobile,
|
||||||
умолчание `Rest.DefaultSec`); двойная — выход пульса за красную
|
умолчание `Rest.DefaultSec`); двойная — выход пульса за красную
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@
|
||||||
|
|
||||||
## 7. Чек-лист шага 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.
|
2. Логин Garmin Connect из C# (FitFetcher) — итерация 2.
|
||||||
3. Vosk: точность команд в шумном зале ≥ КУ-1 — итерация 2.
|
3. Vosk: точность команд в шумном зале ≥ КУ-1 — итерация 2.
|
||||||
4. Сопоставление телефон ↔ .fit на реальной тренировке — итерация 3.
|
4. Сопоставление телефон ↔ .fit на реальной тренировке — итерация 3.
|
||||||
|
|
|
||||||
|
|
@ -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/)
|
||||||
|
|
@ -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-сборке
|
||||||
|
агента.
|
||||||
|
|
@ -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 → вибрация (сброс сработал).
|
||||||
|
|
@ -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. нулевой/отсутствующий пульс — «—», поле живое (не падает).
|
||||||
|
|
@ -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"`.
|
||||||
|
|
@ -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).
|
||||||
|
|
@ -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 собираются только заменой
|
||||||
|
`<iq:product>` в манифесте (правки кода — нулевые; проверочные
|
||||||
|
манифесты во временной папке, в репо не попадают). Симулятор 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 <prg> 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` вручную.
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
bin/
|
||||||
|
*.prg
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<iq:manifest xmlns:iq="http://www.garmin.com/xml/connectiq" version="1">
|
||||||
|
<iq:application entry="FenixDataField" id="34F470FFB5D14141AD06C483FA79A1F8" launcherIcon="@Drawables.LauncherIcon" minSdkVersion="2.1.0" name="@Strings.AppName" type="datafield">
|
||||||
|
<iq:products>
|
||||||
|
<iq:product id="fenix6xpro"/>
|
||||||
|
</iq:products>
|
||||||
|
<iq:permissions/>
|
||||||
|
<iq:languages>
|
||||||
|
<iq:language>eng</iq:language>
|
||||||
|
<iq:language>rus</iq:language>
|
||||||
|
</iq:languages>
|
||||||
|
<iq:barrels/>
|
||||||
|
</iq:application>
|
||||||
|
</iq:manifest>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
project.manifest = test-manifest.xml
|
||||||
|
base.sourcePath = source;test-source
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
project.manifest = manifest.xml
|
||||||
|
base.sourcePath = source
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<resources>
|
||||||
|
<strings>
|
||||||
|
<string id="AppName">Феникс</string>
|
||||||
|
<string id="ZoneBlue">восстановление</string>
|
||||||
|
<string id="ZoneGreen">лёгкая</string>
|
||||||
|
<string id="ZoneYellow">средняя</string>
|
||||||
|
<string id="ZoneRed">высокая</string>
|
||||||
|
<string id="ZoneNone">нет данных</string>
|
||||||
|
<string id="Unit">уд/мин</string>
|
||||||
|
<string id="Set">ПОДХОД</string>
|
||||||
|
<string id="Rest">ОТДЫХ</string>
|
||||||
|
<string id="Avg">ср</string>
|
||||||
|
<string id="RestSecTitle">Отдых, секунд</string>
|
||||||
|
<string id="RestSecError">Отдых: 30–600 с</string>
|
||||||
|
<string id="HrMaxBpmTitle">Макс. ЧСС (0 = формула Танаки по возрасту)</string>
|
||||||
|
<string id="HrMaxBpmError">Макс. ЧСС: 0–250</string>
|
||||||
|
<string id="AgeTitle">Возраст (для формулы Танаки)</string>
|
||||||
|
<string id="AgeError">Возраст: 14–100</string>
|
||||||
|
</strings>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<resources>
|
||||||
|
<bitmaps>
|
||||||
|
<bitmap id="LauncherIcon" filename="launcher_icon.png" />
|
||||||
|
</bitmaps>
|
||||||
|
</resources>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 223 B |
|
|
@ -0,0 +1,18 @@
|
||||||
|
<resources>
|
||||||
|
<properties>
|
||||||
|
<property id="restSec" type="number">120</property>
|
||||||
|
<property id="hrMaxBpm" type="number">0</property>
|
||||||
|
<property id="age" type="number">45</property>
|
||||||
|
</properties>
|
||||||
|
<settings>
|
||||||
|
<setting propertyKey="@Properties.restSec" title="@Strings.RestSecTitle">
|
||||||
|
<settingConfig type="numeric" min="30" max="600" errorMessage="@Strings.RestSecError"/>
|
||||||
|
</setting>
|
||||||
|
<setting propertyKey="@Properties.hrMaxBpm" title="@Strings.HrMaxBpmTitle">
|
||||||
|
<settingConfig type="numeric" min="0" max="250" errorMessage="@Strings.HrMaxBpmError"/>
|
||||||
|
</setting>
|
||||||
|
<setting propertyKey="@Properties.age" title="@Strings.AgeTitle">
|
||||||
|
<settingConfig type="numeric" min="14" max="100" errorMessage="@Strings.AgeError"/>
|
||||||
|
</setting>
|
||||||
|
</settings>
|
||||||
|
</resources>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<resources>
|
||||||
|
<strings>
|
||||||
|
<string id="AppName">Phoenix</string>
|
||||||
|
<string id="ZoneBlue">recovery</string>
|
||||||
|
<string id="ZoneGreen">easy</string>
|
||||||
|
<string id="ZoneYellow">moderate</string>
|
||||||
|
<string id="ZoneRed">high</string>
|
||||||
|
<string id="ZoneNone">no data</string>
|
||||||
|
<string id="Unit">bpm</string>
|
||||||
|
<string id="Set">SET</string>
|
||||||
|
<string id="Rest">REST</string>
|
||||||
|
<string id="Avg">avg</string>
|
||||||
|
<string id="RestSecTitle">Rest timer, seconds</string>
|
||||||
|
<string id="RestSecError">Rest must be 30-600 s</string>
|
||||||
|
<string id="HrMaxBpmTitle">Max HR, bpm (0 = Tanaka formula by age)</string>
|
||||||
|
<string id="HrMaxBpmError">Max HR must be 0-250</string>
|
||||||
|
<string id="AgeTitle">Age (for the Tanaka formula)</string>
|
||||||
|
<string id="AgeError">Age must be 14-100</string>
|
||||||
|
</strings>
|
||||||
|
</resources>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
<iq:manifest xmlns:iq="http://www.garmin.com/xml/connectiq" version="1">
|
||||||
|
<iq:application entry="TestEntry" id="34F470FFB5D14141AD06C483FA79A1F9" launcherIcon="@Drawables.LauncherIcon" minSdkVersion="2.1.0" name="@Strings.AppName" type="watch-app">
|
||||||
|
<iq:products>
|
||||||
|
<iq:product id="fenix6xpro"/>
|
||||||
|
</iq:products>
|
||||||
|
<iq:permissions/>
|
||||||
|
<iq:languages>
|
||||||
|
<iq:language>eng</iq:language>
|
||||||
|
<iq:language>rus</iq:language>
|
||||||
|
</iq:languages>
|
||||||
|
<iq:barrels/>
|
||||||
|
</iq:application>
|
||||||
|
</iq:manifest>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue