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:
virus-home 2026-10-19 17:56:34 +05:00
parent d4867acefd
commit 2f8c6095a7
30 changed files with 1505 additions and 7 deletions

View File

@ -46,8 +46,8 @@ JSON-тела (версии программ, факты .fit) — `jsonb` вн
| m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ✅ (2026-10-19: Room 9 таблиц + WorkoutCore + SyncWorker/ApiClient + DataStore; APK 4,2 МБ, unit 18/18; смок sync 5 сущностей против живого сервера пройден, errata client_guid-UUID + guid-колонки daily_log/profile) |
| m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ✅ (2026-10-19: 8 экранов+VM, Compose Material 3 тёмная, unit 36/36, APK 12 МБ, живой смок контура программы; errata контрактов camelCase/flat program_version/sex m-f; запуск на устройстве — приёмка владельца) |
| m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ✅ (2026-10-19: парсер Ф-1.2 + VoskEngine с грамматикой/fallback + ModelStore (filesDir, не в APK) + executor через WorkoutCore 🛡 + TTS/сигналы + Mic-кнопка/RECORD_AUDIO + блок настроек; unit 63/63, APK 57 МБ; живой URL модели 200 OK; распознавание на устройстве — приёмка владельца, КУ-1 вручную) |
| m12-watch-channel | `WatchChannel`: 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 параллельно

View File

@ -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`); двойная — выход пульса за красную

View File

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

View File

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

View File

@ -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-сборке
агента.

View File

@ -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 → вибрация (сброс сработал).

View File

@ -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. нулевой/отсутствующий пульс — «—», поле живое (не падает).

View File

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

View File

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

View File

@ -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` вручную.

View File

@ -0,0 +1,2 @@
bin/
*.prg

View File

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

View File

@ -0,0 +1,2 @@
project.manifest = test-manifest.xml
base.sourcePath = source;test-source

View File

@ -0,0 +1,2 @@
project.manifest = manifest.xml
base.sourcePath = source

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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