
Обратно спроектированный протокол BLE для CMF Watch Pro 2, документирующий структуру GATT, зашифрованные командные фреймы AES-128-CBC, процедуру аутентификации и синхронизацию данных о здоровье для разработки альтернативного сопутствующего приложения.
Неофициально. Этот документ описывает протокол Bluetooth Low Energy (BLE) часов CMF Watch Pro 2 (CMF by Nothing), восстановленный методом обратной разработки для альтернативного приложения-компаньона. Он не связан с Nothing/CMF и не одобрен ими. Используйте на свой страх и риск.
Все многобайтовые целые числа в заголовке кадра и кодах операций — big-endian. Целые числа
внутри полезной нагрузки команд — little-endian, если не указано иное (это отражает
прошивку устройства) — остерегайтесь исключений (GOALS_SET, GPS_PUSH, смещение/длина
массовой передачи — big-endian).
Каждое неочевидное утверждение ниже помечено способом его установления:
Если более поздний раздел исправляет более ранний, ранний текст сохраняется с указателем, а не удаляется — знание того, какие чтения были опробованы и опровергнуты, избавит следующего человека от того же обходного пути.
Тестовое устройство для всех захватов: CMF Watch Pro 2-5485, прошивка 1.0.0.73, серийный
номер CI04102520008192, MCU Actions ATS3089C (Cortex-M4), экран 466×360.
Телефон является GATT-клиентом; часы — периферийным устройством, рекламирующимся как
CMF Watch Pro 2-XXXX (4 шестнадцатеричных символа).
Включите уведомления, записав 01 00 в каждый CCCD (00002902-…). Канал команд
(fff1/fff2) передаёт кадрированный протокол ниже. Канал оболочки (77d4…) передаёт
обычный текст в стиле AT (например, AT GETSECRET; см. §14). Канал данных (02f0…)
передаёт большие бинарные блоки (циферблат, прошивка, AGPS), координируемые управляющими
кодами операций на канале команд.
UUID сервисов — часы рекламируют ~10 основных сервисов. Перечислены на реальном устройстве:
0xfff0 (команды), 0x180f (батарея), 0x180a (информация об устройстве), 0xefe7, 0xffd0,
02f00000-…ffe0 и 02f00000-…fe00 (данные), 77d4e67c-2fe2-2334-0d35-9ccd078f529c (оболочка /
сопряжение), e49a3001-f69a-11e8-8eb2-f2801f1b9fd1, f48a23c0-f69a-11e8-8eb2-f2801f1b9fd1.
⚠️ UUID сервиса оболочки —
77d4e67c-…, а не77d4ff00-…. В более ранних версиях этого документа предполагалось, что сервис разделяет префиксff00своих характеристик (77d4ff01/77d4ff02, §14) — это не так, по крайней мере на устройстве, на котором это проверялось (находка из freethinkel/fmc, см. §Источники). UUID характеристик не изменились. Не проверено, стабилен ли77d4e67cмежду устройствами — перечисляйте, а не зашивайте жёстко.
🌐 Примечание о Web Bluetooth. Chromium обнаруживает только те сервисы, которые страница перечислила в
optionalServices, даже для нефильтрованного вызоваgetPrimaryServices()— страница, перечисляющая 3 сервиса, видит 3, тогда какchrome://bluetooth-internals(собственный C++-слой Chrome, без ограничений) показывает все 10. Если вы пишете браузерный клиент, перечислите все UUID выше заранее, иначе сопряжение завершится ошибкой с сервисами, которые явно существуют. Web Bluetooth нет в Firefox/Safari; требуется жест пользователя + HTTPS/localhost.
✅ Целая реальная сессия прошла на единственном канале команд — во время 160-секундного захвата при интенсивном использовании не было трафика на каналах данных/прошивки или оболочки, кроме явной передачи OTA/циферблата.
0xF5)Каждое сообщение канала команд обёрнуто в один или несколько кадров с 11-байтовым заголовком:``` +------+-----------+--------+-------------+-------------+--------+-------------------+ | 0xF5 | chunkLen | cmd1 | chunkCount | chunkIndex | cmd2 | chunk bytes … | | 1 B | 2 B (BE) | 2 B BE | 2 B BE | 2 B BE | 2 B BE | chunkLen bytes | +------+-----------+--------+-------------+-------------+--------+-------------------+ __________________________ 11-byte header ____________________________/
- `cmd1`/`cmd2` вместе образуют **опкод** (см. §6). 🔎 подтверждено по фрейм-билдеру официального приложения (`C6117b.m30831g`).
- `chunkCount` = общее количество чанков для этой команды; `chunkIndex` — **с 1**.
- `chunkLen` = количество байт `chunk` в этом фрейме.
- Одна BLE-запись может фрагментироваться по MTU канала; приёмник буферизует сырые байты и заново извлекает полные фреймы. Большие полезные нагрузки разбиваются на несколько чанков (одинаковые `cmd1/cmd2`, возрастающий `chunkIndex`) и собираются по порядку.
### Соглашение об опкодах (✅ подтверждено на проводе)
- `cmd1 = 0xFFFF`: `cmd2` в диапазоне `0x80xx`/`0x90xx` = телефон→часы (запрос/установка); `0x00xx`/`0xa0xx` = часы→телефон (ответ). Пары сопоставляются по младшему байту (`0x9055`↔`0xa055`, `0x8051`↔`0x0051`).
- специфичный для функции `cmd1`: суффикс `cmd2` = `0x0001` **SET**, `0x0002` **GET**, `0x0003` **ACK**.
### Тело чанка
Для каждого чанка тело — это `payloadPiece ‖ CRC32_LE(payloadPiece)` (4-байтный CRC, little-endian, zlib/IEEE). Если команда **зашифрована** (см. §3), весь `payloadPiece ‖ CRC` затем шифруется AES-128-CBC/PKCS7, и этот шифротекст становится `chunk` фрейма.
**Особенность открытого текста:** для опкодов без шифрования часы *учитывают* 4-байтный CRC в `chunkLen`, но **не** передают его. Поэтому при декодировании фрейма с открытым текстом фактическая длина данных равна `chunkLen − 4`. (Зашифрованные фреймы несут CRC внутри шифротекста как обычно.)
Размер чанка (чтобы зашифрованные чанки ложились на границы AES-блоков), при `maxWrite = mtu − 3`:
- зашифрованный: `floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- открытый текст: `maxWrite − 11 − 4 − 1`
✅ Все наблюдаемые значения `chunkLen` зашифрованных фреймов были кратны 16 (выравнивание по блокам соблюдается).
---
## 3. Криптографические примитивы
- **AES-128-CBC** с дополнением **PKCS7** и **фиксированным IV** (из прошивки `CmfCharacteristic.AES_IV`):
`50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 5A`.
- **CRC32** (zlib/IEEE), выдаётся как 4 байта little-endian.
- **SHA-256** по конкатенации частей.
Вывод ключа:```
authkey = SHA256( rnd1 ‖ rnd2 ‖ secret )[0..16] // persisted across sessions
sessionKey = SHA256( nonce ‖ authkey )[0..16] // per connection
secret = 16-байтовый секрет устройства (получается с часов через shell-команду
AT GETSECRET → GETSECRET:<32-hex>,OK).rnd1 = 16 случайных байт, выбранных телефоном; rnd2 = 16 случайных байт с часов.nonce = байты из ответа nonce часов.После установки ключа все кадры командного канала шифруются AES, кроме открытых опкодов, перечисленных в §5.
✅ Обе производные проверены: authkey, восстановленный с рутованного телефона из ntwatch.db, совпал со
значением, полученным из захваченных rnd1/rnd2/secret; sessionKey, воспроизведённый из захваченного nonce,
расшифровывает живые кадры.
Два пути входа используют один и тот же хвост nonce/confirm.
phone → (shell) AT GETSECRET watch → (shell) GETSECRET:<32hex>,OK phone: rnd1 = random16 ; signed1 = SHA256(rnd1 ‖ secret) phone → AUTH_PAIR_REQUEST (plaintext) payload = rnd1(16) ‖ signed1(32) // 48 B watch → AUTH_PAIR_REPLY (plaintext) payload = rnd2(16) ‖ signed2(32) // 48 B phone verifies signed2 == SHA256(rnd2 ‖ secret) phone: authkey = SHA256(rnd1 ‖ rnd2 ‖ secret)[0..16] → set crypto key = authkey phone → AUTH_PHONE_NAME (encrypted) payload = 0xA5 ‖ model(UTF-8) // e.g. "CMF Watch Pro 2" watch → AUTH_WATCH_MAC (encrypted) phone → AUTH_NONCE_REQUEST (encrypted) payload = 0xA5 watch → AUTH_NONCE_REPLY (encrypted) payload = nonce phone: sessionKey = SHA256(nonce ‖ authkey)[0..16] → set crypto key = sessionKey phone → AUTHENTICATED_CONFIRM_REQUEST (encrypted) payload = 0xA5 watch → AUTHENTICATED_CONFIRM_REPLY (encrypted) → state = Initialized
При `AUTH_FAILED (0xFFFF,0xA061)` или несовпадении подписи аутентификация завершается неудачей.
### 4.2 Повторное подключение (authkey уже известен)```
set crypto key = authkey (persisted)
phone → AUTH_PHONE_NAME (encrypted) payload = 0xA5 ‖ model
watch → AUTH_WATCH_MAC (encrypted)
phone → AUTH_NONCE_REQUEST (encrypted) payload = 0xA5
watch → AUTH_NONCE_REPLY (encrypted) payload = nonce
sessionKey = SHA256(nonce ‖ authkey)[0..16] → set crypto key = sessionKey
phone → AUTHENTICATED_CONFIRM_REQUEST (encrypted) payload = 0xA5
watch → AUTHENTICATED_CONFIRM_REPLY (encrypted) → Initialized
✅ Порядок переподключения (без shell-трафика) был подтверждён на реальном захвате.
⚠️→✅
TIMEобязателен перед запросами данных. ПослеInitializedчасы не будут отвечать наBATTERY,SERIAL_NUMBER_GETили рукопожатиеACTIVITY_FETCH_*, пока в сессии не будет отправленTIME (FFFF 8004)— без него приходит только незапрошенныйFIRMWARE_VERSION_RET, а всё остальное истекает по таймауту. ✅ подтверждено вживую (Pixel 8a): отправка трёх GET-запросов безTIME→ отвечает только прошивка; отправкаTIMEпервым → батарея и серийный номер начинают отвечать.
Рекомендуемый порядок фазы 2: TIME → FIRMWARE_VERSION_GET → SERIAL_NUMBER_GET →
BATTERY (0xA5) → отправка конфигурации → синхронизация здоровья (§8).
Для большинства настроек нет отдельного опкода «чтения». Отправка *_GET (cmd2 = 0x0002, payload
0xA5) заставляет часы ответить опкодом SET (cmd2 = 0x0001) с текущим значением.
SET-команды подтверждаются через cmd2 = 0x0003 с пустым телом.
Кадры шифруются AES после установки ключа, кроме следующих опкодов, которые всегда передаются открытым текстом:
AUTH_PAIR_REQUEST (FFFF 8047), AUTH_PAIR_REPLY (FFFF 0048)DATA_CHUNK_WRITE_WATCHFACE (FFFF 9064), DATA_CHUNK_WRITE_FIRMWARE (FFFF 9042),
DATA_CHUNK_WRITE_AGPS (FFFF 905F)Заголовки кадров (cmd1/cmd2) всегда передаются в открытом виде, поэтому последовательность команд видна в
любом захвате даже без ключа — только зашифрованные payload требуют sessionKey.
(cmd1, cmd2)GET/SET/REQUEST = телефон→часы; RET/REPLY/ACK/RESPONSE/DATA = часы→телефон.
| Имя | cmd1,cmd2 |
|---|---|
| MUSIC_INFO_SET / _ACK | FFFF 905C / FFFF A05C |
| MUSIC_BUTTON | FFFF A05D |
| Имя | cmd1,cmd2 |
|---|---|
| WEATHER_SET_1 (тот, что работает) | FFFF 906B |
| WEATHER_SET_2 (игнорируется на Pro 2 — см. §9) | 0066 0001 |
Опкоды только для JS (
FFFF 8051,FFFF 0051,FFFF 90A2,FFFF 90C5,FFFF A056,FFFF 908A/908Bстатус/поддержка ChatGPT) обрабатываются в байткоде Hermes приложения, а не в Java- слое. Их заголовки появляются в захватах, но семантика payload ⚠️ [неопределённа].
Циферблаты / прошивка / AGPS используют цикл init → запрос/запись чанков → finish-ack:
(все cmd1 = FFFF.) Часы управляют циклом, отправляя DATA_CHUNK_REQUEST_*(offset, length)
(offset/length = u32 big-endian); телефон отвечает DATA_CHUNK_WRITE_* с
payload[offset..offset+length] на характеристике данных. Подробности см. в §11–§12.
Payload TIME (FFFF 8004) = epochSeconds(i32, BE) ‖ utcOffsetMillis(i32, BE). Отправляется сразу после
аутентификации, чтобы часы показывали локальное время (и разблокировали запросы данных — см. §4.3).
⚠️ Метки времени здоровья от часов — UTC. Приложение-компаньон должно добавлять локальное смещение UTC перед вычислением локального календарного дня / времени суток. (Группировка здоровья по сырому дню UTC переносит смену дня на неправильное локальное время.)
Payload TIME_FORMAT (005F 0001) = 1 байт: 00 = 24 ч, 01 = 12 ч.
ACTIVITY_FETCH_1; часы отвечают ACTIVITY_FETCH_ACK_1 (первый байт 01 ⇒ готово).ACTIVITY_FETCH_2; затем часы отправляют поток кадров данных:
ACTIVITY_DATA, HEART_RATE_*, SPO2, STRESS, SLEEP_DATA, WORKOUT_SUMMARY[_V3].Синхронизация последовательная (должна следовать за TIME; часы освобождают потоки после ACK_2), а не
одиночный пакет. Тяжёлая сессия отправляет ~170–210 кадров уведомлений за ~160 с. ✅
ACTIVITY_DATA (по 32 байта, LE) ✅Единица калорий: калории активности сообщаются в кал (грамм-калориях). Разделите дневную сумму на 1000, чтобы получить ккал. (Калории в сводке тренировки, напротив, уже в ккал.)
timestamp(i32 LE) ‖ value(i32 LE)
(value = уд/мин / % SpO₂ / индекс стресса).00DA 0001) отличается — 5 байт: timestamp(i32 LE) ‖ hr(u8).
✅ живой пример 5e dc 29 6a 4e → ts, hr = 78 уд/мин. Диапазоны оценки стресса: 1–29 / 30–59 / 60–79 / 80–99.SLEEP_DATA (заголовок 18 байт + N × 8-байтовых записей) ✅Один SLEEP_DATA = одна сессия сна; ночь может содержать несколько (микропробуждения разделяют сессии).
Заголовок:
Каждая 8-байтовая запись: timestamp(u32) ‖ duration_s(u16) ‖ stage(u16).
Коды стадий: 1 = Глубокий, 2 = Основной/лёгкий, 3 = БДГ, 4 = Бодрствование. ✅ проверено на полной ночи
(две сессии, суммы D/C/R/A сходятся).
WORKOUT_SUMMARY v1 (54 байта) / _V3 (0160 0001)v1: start(u32), end(u32), duration_s(u32), затем тип/калории/шаги/дистанция/средний HR и
блок GPS/расширенный. ✅ раскладка v1 подтверждена по прошивке. WORKOUT_SUMMARY_V3 — более новая раскладка
для тех же данных плюс расширенный блок ~40 байт (exerciseLoad, аэробная/анаэробная, recoveryTime,
VO₂max, каденс, PAI, лучшие времена бега…). Набор полей известен (из Room DB приложения), но
точные байтовые смещения внутри этого 40-байтового блока ⚠️ [неопределённы] — для их закрытия нужен один
сырой захват GPS-тренировки.
Строки — UTF-8, усечённые по байтам до размера поля (усечение может разбить многобайтовый символ,
соответствуя поведению s.encode()[:max] в прошивке); короткие поля дополняются нулями справа.
0065 0001) ✅: iconCode(1) ‖ 0x00 ‖ when(u32 BE) ‖ titleLen(1) ‖ title ‖ body.
iconCode выбирает иконку приложения (WhatsApp=8, Telegram=12, Instagram=18, Gmail=27; неизвестно=0xFF).
Заголовок ≤ 20 байт, тело ≤ 128 байт. Отправлено с клиента → часы отобразили его + ACK 0065 0003.005C 0001) ✅: ответ = level(1) ‖ charging(1) (например, 3b 00 = 59 %, не заряжается).00DE 0001) ✅: len(1) ‖ ASCII (например, 10 + "CI04102520008192").0095 0001) ✅: height_cm(1) ‖ weight_kg(1) ‖ age(1) ‖ gender(1: 1=М)
(например, = 172 см / 73 кг / 31 / мужской).now/utc_offset как явные параметры
(детерминированно, тестируемо). Транспорт предоставляет реальное время.TIME управляет всем (§4.3) — отправьте его первым, иначе часы останутся немыми на запросы данных.GOALS_SET и
GPS_PUSH — big-endian, а смещение/длина массовой передачи — big-endian.Часы поддерживают (a) фото/пользовательские диски (фоновое изображение + цифровые часы, рисуемые прошивкой) и (b) структурированные диски (встроенные / магазинные циферблаты: фон плюс позиционированные слои спрайтов, стрелки и текстовые виджеты). Оба передаются по каналу данных через цикл init → чанк в §6.
Что реально работает (✅ проверено вживую): создание фото-диска из любого изображения и его установка; установка любого из 103 магазинных циферблатов офлайн; перекраска структурированного диска (замена фона или любого не-фонового спрайта) и перемещение его слоёв; переупорядочивание / переключение активного циферблата; и создание структурированного диска с нуля — конверт сцены
0x20декодирован, и построитель реализован (§11.7), доказано офлайн, что все 103 магазинных циферблата проходят round-trip байт-в-байт и что синтетические контейнеры проходят собственный валидатор прошивки. 🟡 единственный непроверенный шаг — наблюдение синтетического рендера с нуля на устройстве через9075(структурное офлайн-доказательство уже покрывает то, что раньше вызывало отклонение0a). Нет барьера кодека или транспорта и нет необходимости в вендорском тулчейне. Старые утверждения «структурированный рендер запечён в RES-pack / невозможен по BLE» и «серверный кодек cf=0x1f» были неверны (баг смещения+байт-на-пиксель) — прошивка рендерит структурированные циферблаты управляемо данными из файла, который вы отправляете.
DIAL_COMMAND (9055 / a055) ✅a055 = result(u8) ‖ selectIndex(u8) ‖ total(u8) ‖ max(u8) ‖ N × dialId(u32 LE) ‖ ffffffff. Пример: 01 05 06 07 … = активен #5, 6 циферблатов, максимум 7.CHANGE_DIAL (009F 0001) неактивен на прошивке 1.0.0.73 (возвращает константу, не переключает) — не
используйте его.INIT1 8052 (payload A5) → 0052 [0]=01 INIT2 9063 (photo, APPEND) | 9075 (structured, REPLACE) → A063 / A075 [0]=01 [ watch → DATA_CHUNK_REQUEST A064 (offset, length; u32 BE, +progress u8) phone → DATA_CHUNK_WRITE 9064 (bytes[offset..offset+length], plaintext) ] × N FINISH A065 → 9065 (payload A5)
Finish reply byte: `01` = активировано и сохранено; `0a` = сохранено, но **не** активировано / отклонено. На
Android каждая `DATA_CHUNK_WRITE` должна отправляться как **одна BLE-запись на кадр** — объединение и
повторная нарезка по MTU рассинхронизирует заголовки, и часы зацикливаются, запрашивая offset 0.
- **`9063` (фото) = APPEND.** Список циферблатов растёт (6→7); `watchfaceId = 0xFFFFFFFF` (кастомный
sentinel), поэтому он никогда не отклоняется как дубликат, и часы автоматически активируют его.
- **`9075` (структурированный) = REPLACE** слот `old_id`. `old_id` **обязан** уже быть в списке
(иначе `0a`). Чтобы переустановить уже присутствующий id, **сначала удалите его** (9055 list-minus-id),
затем загружайте «свежий» — повторное использование id на месте даёт `0a`.
### 11.3 Фото / кастомный циферблат — ✅ полностью проверен end-to-end
**Контейнер** (побайтово проверенный round-trip; все поля little-endian):```
0x00 magic 6c 8d c4 a5
0x04 count 12 00 00 00 (=18) [constant, NOT an element count]
0x08 00 × 8
0x10 lenFull u32 LE (length of the whole FULL block: tag+len+payload)
0x14 FULL tag 04 48 47 3a ‖ payloadLen(u32 LE) ‖ LZ4(RGB565-LE) → 466×466 [raw 434312 B]
THUMB tag 04 38 c4 21 ‖ payloadLen(u32 LE) ‖ LZ4(RGB565-LE) → 270×270 [raw 145800 B]
EOF-4 magic 6c 8d c4 a5 [trailer = magic repeated]
Codec = стандартный блок LZ4 поверх RGB565 little-endian, сверху вниз (payloadLen отсчитывается от
первого байта LZ4). Официальное приложение использует LZ4-HC и удаляет 21-байтовый заголовок/хвост блока LZ4; обычный
энкодер LZ4 только с литералами также работает — часы принимают любой корректный LZ4, побайтовая идентичность не
требуется. Пиксели за пределами вписанной окружности (центр 233,233, радиус 233) устанавливаются в 0x0000.
INIT_2 для 9063 — точный заголовок (✅ именно этот работает):```
01 ‖ size(u32 BE) ‖ FF FF FF FF ‖ 01 01 01 ‖ styleId(u16 BE) ‖ posX(u16 BE) ‖ posY(u16 BE) ‖
color565(u16 BE) ‖ FF × 8
`size` = точная длина `.bin`; `FFFFFFFF` = пользовательский `watchfaceId`; `styleId` 0–4 выбирает встроенный
макет цифровых часов (он всегда отрисовывается — «выключить» нельзя); `posX/posY` задают его позицию (проверенные
56 / 77); `color565` задаёт его оттенок (например, `FFFF` = белый). ⚠️ Более короткая форма `A5 ‖ size ‖ watchfaceId`
**отклоняется** с завершением `0a` — используйте полный заголовок выше. (Эталонная реализация:
`core-rust/engine.rs::build_wf_init2`, повторяющая `C6135t.m31104u` в официальном приложении.)
**Рецепт:** измените размер изображения до 466×466 (и миниатюры 270×270), преобразуйте в RGB565-LE сверху вниз,
при необходимости обнулите пиксели за пределами круга, сожмите каждое LZ4, соберите контейнер выше и загрузите
через конвейер `9063` с `watchfaceId = 0xFFFFFFFF`. (Эталонный кодек: `core-rust/watchface.rs`,
`work/codec_dfa.py`.)
### 11.4 Структурированный / магазинный циферблат — контейнер и кодеки ✅
**Расположение файла** — 36-байтовый заголовок повторяется **побайтно идентично как 36-байтовый нижний колонтитул** в конце файла
(✅ проверено на 15 циферблатах; парсер должен отклонять файл, если они различаются):```
[36-byte header][scene TLV (§11.7)][asset pool][36-byte header again]
Заголовок (идентичная структура для всех 103 дисков хранилища; все поля в формате little-endian):``` 0x00 crc_tree u32 LE [CRC32-raw of header[0x04:0x24] ‖ scene section] ✅ see below 0x04 magic 01 00 00 XX [XX = 0x00 or 0x02; both seen, meaning of 0x02 unknown] 0x08 name char[16] [NUL-terminated, e.g. "SlopeTime", "Metaball"; may carry a non-zero tail after the NUL (@0x17) — round-trip it verbatim] 0x18 size_a u32 LE [= filesize − 36 = footer offset = header+body] ✅ 103 dials 0x1c size_b u32 LE [asset-pool length, exactly] ✅ 15 dials 0x20 crc_assets u32 LE [CRC32-raw of the asset pool] ✅ see below 0x24 … [body starts here: the 0x20 scene container, §11.7]
> ⚠️ **Исправление (заменяет «нет блокирующей контрольной суммы»).** В более ранних ревизиях `@0x00` читался как
> per-dial идентификатор/хеш, а `@0x20` — как «3× u32 слова идентификатора/хеша \[не CRC]», и утверждалось, что CRC32/Adler32/
> байтовая сумма — все не совпадают. Оба слова **являются** CRC32 — более ранние тесты их пропустили, потому что
> вариант нестандартный, и потому что чтение «3 слова по адресу `0x20`» смешивало единственное слово CRC
> с первыми байтами контейнера сцены, который начинается по адресу `0x24` (аналогично «имя, повторённое по адресу
> `0x2c`» — это узел имени `0x86` сцены, §11.11). Находка из
> [freethinkel/fmc](https://github.com/freethinkel/fmc); повторно проверено здесь.
**CRC32-raw** = отражённый IEEE-полином `0xEDB88320`, **`init = 0`**, и **без финального XOR** — т.е.
ни `init=0xFFFFFFFF`, ни `^0xFFFFFFFF` стандартного `crc32`. Именно поэтому
готовый CRC32 никогда не совпадал. Обратите внимание на зависимость порядка: `crc_assets` находится внутри диапазона,
покрываемого `crc_tree`, поэтому **сначала записывайте `@0x20`, затем вычисляйте `@0x00`**.```python
def crc32_raw(data: bytes) -> int: # tab = standard 0xEDB88320 reflected table
c = 0 # init 0, no final inversion
for b in data: c = tab[(c ^ b) & 0xFF] ^ (c >> 8)
return c & 0xFFFFFFFF
crc_tree = crc32_raw(f[0x04:0x24] + f[0x24:first_asset])
crc_assets = crc32_raw(f[first_asset:len(f)-36])
Verified: 9/9 pristine store dials match on both words, and 6/6 of this repo's own templates
match on crc_tree.
🟡 The firmware does not appear to enforce either CRC. Every dial this repo has installed over
9075— including reskins produced by the same-footprint in-place edit path (§11.6), which mutates asset payloads and X/Y bytes without recomputing the header — rendered fine on-device. So a stale CRC is not what causes a0areject (that is the container-window invariant, §11.7). Treat the CRCs as write-correct-anyway: cheap, and the only known integrity field in the format. Anything that rewrites the scene or the asset pool should recompute both words.
Stub dials (~173 B, e.g. ids 273/274/277) are placeholders for faces baked into ROM: header + directory, no real assets.
Assets — each is dimsWord(u32 LE) ‖ len(u32 LE) ‖ LZ4(payload), where
cf = dimsWord & 0x1f, w = (dimsWord >> 10) & 0x7FF, h = (dimsWord >> 21) & 0x7FF, and len
counts from the first LZ4 byte (the 1f 00 01 00 you often see there is the first LZ4 token — do
not skip it). Decompressed size = w·h·bpp:
✅ All 4151/4151 assets across the 103 dials decode exactly with a standard lz4.block
decompressor at w·h·bpp. Transparency is the alpha byte (cf=5/24) or 0x0000 (cf=4 outside the
circle) — there is no RLE and no "escape". Encode = re-raster → standard LZ4 → [dimsWord][len][LZ4].
INIT_2 for 9075 — AES-encrypted body:```
kind(1) ‖ old_id(u32 LE) ‖ new_id(u32 LE) ‖ file_len(u32 LE)
`kind` = `0x02`/`0x03`; `old_id` = текущий активный циферблат (из `9055`); `file_len` = реальный размер `.bin`
(= `@0x18 + 36`). Установка магазинного `.bin` как есть — гарантированный путь (Ring Data id 359 +
102 других подтверждены). (Ссылка: `core-rust/engine.rs::build_dial_replace_init`.)
### 11.5 Структурированная грамматика директории ✅ (декодирована и реализована — ПЕРЕСМОТРЕНО 2026-07-02)
> **⚠️ Пересмотр (2026-07-02): плоская схема записи `61 01 00` ниже была систематически
> СО СДВИГОМ НА ОДИН БАЙТ.** Тело сцены — это чистый TLV (§11.7); тело **листового** drawable (теги `0x30`/`0x38`
> статические, `0x70` указатель) выглядит так:
>
> ```
> 01 xx 00 [X u16][Y u16] …attrs… 61 [count u16][base u32][count×id u16] [05 05 00 01 pivX pivY]
> ```
>
> - атрибут `0x01` открывает тело: **X,Y = верхний левый угол** на холсте 466² (это `s16 x,y` из SDK
> `sty_picture_t`).
> - **таблица кадров `61 …` закрывает тело** (`base` = указатель на ассет; `count` 1 = изображение, 10/11 =
> атлас цифр — старый «тип записи `0a/0b`» на самом деле был этим count! — 7/13/2 = лист кадров усложнения).
> - дополнительные указатели: источник+масштаб `[src] 00 3c 00` внутри атрибута `0x01`; точка поворота в
> **хвосте** `05 05 00 01 [pivX][pivY]`. **Центр вращения = `(X+pivX, Y+pivY)` для каждого указателя** —
> не фиксированный (233,233): существуют смещённые от центра субциферблаты (например, стрелки циферблата 366 вращаются вокруг 150,150).
>
> Линейное сканирование `61 01 00` сшивало таблицу кадров+точку поворота элемента **N** с X/Y
> (и байтом тега, старым «f3») элемента **N+1** — это лишь *выглядело* правильным на аналоговых циферблатах, где
> соседние стрелки имеют почти идентичную геометрию. «Стена компактных вариантов» (спецификация 24 §24.4.5) была
> тем же неверным прочтением. Реализовано как `scan_scene_drawables` в `core-rust/watchface_struct.rs`
> и `wfweb/src/codec/parse.ts` (сцена = основной источник для изображений/указателей; плоское сканирование сохранено для
> текста + запасной вариант без конверта). Проверено оракулом `wfweb/compare.html` (рендер против официальных
> PNG из магазина, 99 циферблатов): 64→72 хороших, 8→5 плохих, средняя разница 9.3→7.4%.
Историческое чтение плоских записей (заменено, сохранено для контекста):
- **Статическое изображение** (`61 01 00`): `asset_ptr(u32) ‖ elemId(u16) ‖ 05 05 00 01 ‖ pivotX(u16) ‖
pivotY(u16) ‖ 3B ‖ 01 ‖ 1b 00 ‖ X(u16) ‖ Y(u16)`. Верхний левый угол на холсте 466² = `(X−pivotX, Y−pivotY)`.
- **Указатель/стрелка** — та же запись изображения, поворачивается во время выполнения. **Центр вращения = `(X+pivotX, Y+pivotY)`**
(≈ 233,233 на аналоговых циферблатах). **Источник данных — это `u8` по смещению записи `+36`**, масштаб `u16` по
`+38` (=60): `0x0a`/`0x70` = часы (`h·30°+m·0.5°`), `0x0e`/`0x71` = минуты (`m·6°+s·0.1°`),
`0x12`/`0x72` = секунды (`s·6°`). ✅ подтверждено дизассемблированием геттеров (запасной вариант RTC 10:10:30).
- **Виджет текста / числа** (`61 0a 00`): `asset_ptr(u32) ‖ [10×u16 метрики шрифта] ‖ 40 01 00 ‖ флаг ‖
3B ‖ 01 ‖ u16 ‖ X(u16) ‖ Y(u16)`. `asset_ptr` указывает на глиф «0»; цифра *d* = ассет по
`index("0") + d` (10 последовательных спрайтов cf=5, например `0123456789` и пунктуация `,°`). ✅ отрендерено.
- **Заполнение усложнения = индекс кадра** (✅ подтверждено для цифровых/перечисляемых/шкальных усложнений, count>1):
значение индексирует **предварительно отрендеренный лист кадров** в `.bin` — `frame = (count−1)·val/100` (проценты) или
`frame = value` (перекидная цифра / перечисление). Таблица кадров = подзапись `61 ‖ count(u16) ‖ base(u32) ‖
count×id(u16)`. Например, большие часы 327 Digit Max — это лист из 13 кадров (числа 0–12), `frame = hour`.
- **Кольцо прогресса / дуга = секторный клип во время выполнения** (✅ 2026-07-02, **исправляет чтение «кольца — это листы кадров»
в спецификации 25 §2**): тег элемента **`0x81`** несёт **один** полный диск (`61` таблица кадров
`count == 1`), а частичный клин — это тот диск, **обрезанный до сектора круга** (`frac = value/max`,
по часовой стрелке от 12 часов) — проверено попиксельно на 322 Glare 2 и подтверждено `count==1` на
**20 циферблатах**. На диске: тело `0x81` = под `0x01` (геометрия `x@+0 y@+2 w@+4 h@+6`, встроенный `61 1 base`
= диск) + под `0x5b` (спецификация дуги). Реализовано в wfweb (`blendSector`).
⚠️ Подзапись `0x5b` — это **не** просто «`max` u16 `@+4`», как документировалось ранее — это читало
младшую половину `max i32` и пропускало **углы начала/конца развёртки и ширину штриха**, которые находятся сразу
после неё. Также существует процедурный собрат `0x80`/`0x5a` (с явным радиусом), который этот документ
никогда не охватывал. Полная структура записи и то, что не так с прежним предположением «по часовой стрелке от 12 часов»:
**§11.15**.
- **Идентификатор источника данных** — блок атрибутов `82` элемента находится на `delim+3` (после последнего `40 01 00`),
а **идентификатор источника — это `u8` по `+0x14`** (также `relX@+0x07 s16`, `relY@+0x09 s16`, `anchor@+0x0C/0E`,
`mode@+0x15`, `frame-count@+0x1A`). Anchor < 0 = выравнивание по краю родителя. 🔎 Прошивка разрешает
идентификатор через таблицу геттеров из 142 записей по адресу `0x101f371c` (каждая вызывает `ux2sys_get(type)`).
⚠️ **Предпочтительно читать идентификатор как `meta[9]` структуры (§11.11)** — фиксированное поле — вместо этого прямого
сканирования атрибута `82`, которое является источником сдвига на один байт, описанного в §11.8/§11.9. Полная таблица идентификаторов в §16; обратите
внимание, что метки здоровья/погоды, ранее перечисленные в этом разделе (`0x19` ЧСС, `0x1b` батарея,
`0x24` температура, `0x36` шаги), **оспариваются и, вероятно, неверны** — см. блок ⚠️ в §16.
(Пример группы `0x07:0x0b:0x0f` = ЧЧ:ММ:СС ниже не затронут.)
- **Узел группы** (`0x68`): вкладывает свои дочерние элементы в собственное тело TLV (`0x60` = значение/текст,
`0x30` = статический); каждый `0x60` несёт свой идентификатор источника на `data+16`. Например, группа `0x07:0x0b:0x0f` =
часы ЧЧ:ММ:СС. Парсер элементов TLV = `0x100db55c` (таблица переходов, индексируемая по `tag−0x70`).
### 11.6 Матрица авторства
| Путь | Статус | Примечания |
|---|---|---|
| Фотоциферблат из любого изображения | ✅ **готово** | §11.3; проверено на устройстве |
| Установка любого из 103 магазинных циферблатов | ✅ **готово** | §11.4; `9075`, `old_id`=активный |
| Перекраска фона cf=4 магазинного циферблата | ✅ **работает вживую** | заменить полную полезную нагрузку на месте, задать ассету `len` **новый** размер блока (≤ старого), сохранить тот же размер файла, чистая установка |
| Повторное авторство через шаблонирование (замена пикселей любого слоя + перемещение геометрии) | ✅ **рендерится через BLE** | циферблат 373: фон→голубой + спрайт cf=5→красный + перемещён X 224→100, всё отрендерено, стрелки живые |
| 100 %-синтетический структурированный циферблат с нуля | ✅ **билдер готов, проверен офлайн** | билдер конверта `0x20` в `watchface_struct.rs` (`build_container`/`serialize`/`validate_container`); round-trip всех 103 циферблатов байт-в-байт + синтетика проходит валидатор прошивки (§11.7). 🟡 рендер на устройстве через `9075` ещё не снят на видео |
| Системные шрифты (`.font`) | ✅ **декодирование/рендер (все)** | бинарник LVGL (не проприетарный); 32 числовых шрифта (`num*/nm*`, без сжатия) + 24 текстовых шрифта (`font*`, LVGL RLE `comp=1`) — все декодируются: 12208 глифов, 0 переполнений, полный ASCII. RLE = LVGL v8.3 `lv_font_fmt_txt.c` (3-состояния SINGLE/REPEATE/COUNTER + построчный XOR-префильтр), портировано 1:1, без дизассемблирования |
⚠️ Ловушки перекраски/повторного авторства, вызывающие чёрный экран или `0a`: оставление **старого `len` ассета** (часы
читают за пределы блока → переполнение → чёрный экран); **увеличение файла** (отклоняется при установке); повторное использование
идентификатора **на месте** вместо чистой установки; **переупорядочивание ассетов** без исправления цепочки размеров блоков в хвосте ссылок
(§11.11). Сегодня это не фатально, но всё равно пишите правильно: любое изменение сцены или пула ассетов
аннулирует два слова **CRC32** в заголовке — пересчитайте оба (§11.4), `@0x20` перед `@0x00`.
### 11.7 Конверт сцены `0x20` — декодирован и билдер реализован ✅
Повторно авторизованный **настоящий** циферблат рендерится, потому что сохраняет конверт сцены файла. Чисто синтетическое
тело из плоских записей `61 …` **отклоняется** — парсер прошивки (`WFManager_Parser`, `0xdb35c`)
требует, чтобы тело (со смещения `0x24`) начиналось с контейнера сцены `0x20`. Полный файл:```
[0x00,0x24) header: perDialId@0 · version=1@4 · name[16]@8 · size_a@0x18 · size_b@0x1c · idWord0@0x20
[0x24, fa) scene: 20 <u16 L0> ( 21 <u16 L1> ( 86 <len>=name , 30/70/80/81… drawables ) [ 22 … AOD ] )
[fa, EOF) assets: [dimsWord u32][len u32][payload = 1f 00 01 00 + LZ4] …
size_a = filesize−36 · size_b = filesize−36−first_asset · 0x27+L0 == first_asset
Сцена представляет собой чистый вложенный TLV — [tag u8][len u16 LE][body], контейнерные теги 0x20/0x21/0x22/0x68
рекурсивно, листовые отрисовываемые элементы 0x30 (статический) / 0x70 (элемент/указатель) / 0x80 / 0x81 / 0x86 (имя).
(Плоские записи 61 01 00 / 61 0a 00 — это паттерны, которые живут внутри тел отрисовываемых элементов; старый
парсер находил их эвристически — и сшивал соседние тела вместе, см. правку §11.5.
Макет тела отрисовываемого элемента теперь полностью декодирован там.) Каждый дочерний offset+len должен помещаться внутри окна
своего родителя;
первый байт тела ≠ 0x20 → ошибка парсера −16; дочерний элемент, выходящий за пределы своего окна → −2; любое из этих условий
заставляет обработчик 9065 (0xeb50c) записать завершение .
Сборщик реализован и проверен офлайн (core-rust/watchface_struct.rs:
SceneNode / serialize / parse_scene / validate_container / build_container /
build_container_raw; CLI cmfwatch-wfgen reframe):
scene_roundtrip_identity — все 103 магазинных циферблата: parse_scene→serialize воспроизводит сцену
байт-в-байт (пересчитанные вложенные len совпадают), и validate_container проходит на каждом из них.build_reframe_identity / CLI reframe — пересборка всего .bin с нуля воспроизводит
файл байт-в-байт кроме 1 байта выравнивания имени (@0x17; не контрольная сумма).build_container_synthetic — составляет новый циферблат (фон + отрисовываемый элемент, вложенный в 20→21), который
проходит точный инвариант прошивки (build_container генерирует корректные вложенные окна).🟡 Всё ещё не доказано (нужны часы, не блокирует): загрузка синтетического циферблата с нуля через 9075
и наблюдение за его отрисовкой — офлайн-структурное доказательство уже покрывает то, что вызывало отклонение 0a.
Сверил отрисовку wfweb с официальными магазинными миниатюрами (пиксельный оракул по всем 103 циферблатам) и закрыл четыре пробела:
i16 (со знаком). ✅ Якоря могут быть отрицательными для элементов,
выходящих за пределы холста — например, красная секундная стрелка 275-го находится на Y = 0xFFFC = −4 (спрайт 30×281,
источник 0x12, повёрнут от центра за верхний край). Чтение X/Y как u16 (65532) заставляло
защиту отбрасывать его. Разбирайте оба как знаковые и допускайте небольшой отрицательный диапазон.0x60 (не только внутри группы 0x68), и
реальный источник данных — это u8 по смещению записи −5 — прямое сканирование атрибута 82 здесь
систематически со сдвигом на один и захватывает атрибут следующего соседа (в 275-м цифра минут
подхватывала 0x18 дня недели). "10:10" 275-го = час 0x07@X≈306 + минуты 0x0b@X≈369 с
как соседним статическим элементом между ними, каждый — атлас из 11 глифов (). ⚠️ При исправлении X/Y
с , иначе повторный экспорт повредит эти байты (ломает
сохранение размера → ).Также: официальные магазинные миниатюры отрисованы в 10:10 (классическое маркетинговое время), а не 10:12 — совпадение времени оракула с 10:10 заметно снижает среднюю пиксельную разницу. Парсер wfweb теперь выполняет полный цикл по всем 103 циферблатам байт-точно (исправление смещения записи X/Y выше устранило последние несоответствия).
0x22 в собственное представление. ✅ Обходчик сцены уже пропускает 0x22,
но плоское сканирование текста/чисел проходило по всему [0x30, firstAsset) — поэтому оно выводило
вариант always-on (AOD) каждого элемента как обычный слой. На "Gradient" AOD серый атлас даты
(смещение в 0x22) рисовался поверх красного обычного. Исправление: пометить каждую запись 0x22 как
layer.aod=true (с собственным набором дедупликации) и позволить renderAt(…, aod) показывать их только в режиме AOD
(обычный режим скрывает слои aod; режим AOD скрывает обычные; фон заменяется setAod
и рисуется всегда). Чистый выигрыш оракула в обычном режиме по корпусу (284: 31%→21%, +18 других) —
варианты AOD перерисовывали многие циферблаты — и переключатель AOD в редакторе теперь показывает реальный
макет always-on вместо обычного. Настоящий AOD — это чёрный экран (без затемнённой сцены):
если у циферблата нет выделенного фонового кадра AOD (dial.aod), обычная сцена скрывается в режиме AOD,
поэтому отображается чёрный + элементы в их собственном цвете. AOD разбираются
обходчиком сцены тоже (теперь он рекурсивно проходит контейнер , помечая отрисовываемые элементы , вместо того чтобы
оставлять их плоскому сканированию, где их точка поворота не совпадала → "без позиции"); стрелки AOD вращаются вокруг
центра холста ( иногда несёт смещённую от центра x/y стрелки, которую прошивка игнорирует — например,
час Gradient ). Редактор также предоставляет это как
(§UI): каждый экран показывает только свои слои, и правки сохраняются независимо. Отрисовка в обычном режиме
байт-идентична во всём; полный цикл остаётся байт-точным на всех 103 циферблатах.40 01 00 XX (✅ подтверждено прошивкой)Сколько цифр рисует img_number — это один байт в записи поля — байт данных XX
подзаписи атрибута 40 01 00 XX элемента (подзапись 0x40, которая находится после
таблицы кадров 61 [count][base][glyph-ids]):
XX & 0x0F = количество слотов цифр (0 ⇒ по умолчанию прошивки 7).0x80 = дополнение нулями (показывать ведущие нули, например "09" вместо "9").Подтверждено дизассемблированием прошивки (XIP-образ 0x10000000; процедура отрисовки 0x100d8e60):
NDIG = ldrb[40sub+3] & 0x0F (→7 если 0); значение ограничивается value % 10^NDIG, и рисуется ровно NDIG
глифов, начиная со старшего, ведущие нули подавляются, если не установлен бит 7. u16 после источника (60 для
даты, 1000 для ккал) — НЕ количество — он только питает вставку глифа-разделителя тысяч/миллионов
(cmp #1000/#1000000), поэтому его редактирование ничего не давало. Идентификатор источника тоже не ограничивает.
Гистограмма по корпусу по всем 620 числовым полям совпадает: 2-значные поля (час/мин/сек/дата/темп/ЧСС) заканчиваются
40 01 00 02/0x82; ккал …04; шаги …05; однозначные разделения часов 0x81. Таким образом, поле даты
40 01 00 82 = 2 цифры, с дополнением нулями — это и есть вся причина, по которой перепривязанная температура по Фаренгейту
(≥100) обрезалась.
Исправление / редактор: wfweb разбирает digitCount/digitZeroPad (+digitCountOff) для числовых полей,
предоставляет "Цифры" + "Дополнение нулями" в инспекторе, записывает байт на месте (с сохранением размера),
и предпросмотр ограничивает/дополняет до digitCount, чтобы зеркально отражать прошивку. Таким образом, перепривязка источника поля и
установка количества его цифр работает для любого поля (например, дата→температура °F → Цифры 3). Оракул обычного режима
без изменений (0 регрессий, 3 небольших улучшения); полный цикл байт-точен на всех 103 циферблатах.
(Более ранняя гипотеза "ширины цифр"/rectW была неверна — ширина только для макета, а не количество.)
struct и хвост ссылок на ресурсы ✅Сцена (§11.7) — это чистый вложенный TLV — [tag u8][len u16 LE][body]. Полная инвентаризация тегов, наблюдаемая
по корпусу:
✅ Эта инвентаризация полна для корпуса. Рекурсивно проходя только по указанным выше тегам контейнеров,
TLV сцены всех 15 проверенных циферблатов проходит точно до объявленной длины корня с нулевыми неизвестными
тегами — поэтому парсер, обрабатывающий эту таблицу, обрабатывает весь формат, а неизвестный тег означает
смещённое чтение, а не новый тип узла. (Осторожно: обходчик, который рекурсивно спускается в каждый узел, чья длина
случайно ≥ 3, спустится в тела struct/0x5b и выдумает длинный хвост одноразовых
"тегов" — тела листьев не являются TLV.)
💡 Ярлык для авторов: автопозиционирование
0x48/0x68можно полностью пропустить — каждый виджет можно разместить с абсолютнымиx,yпрямо на верхнем уровне экрана, что и делает сборщик с нуля (§11.7). Нужно только для чтения существующих циферблатов. Ширинаmeta0x8000помечает struct как дочерний элемент автопозиционирования кадра (позиция берётся от родителя, а не изx,y).
Тело 0x01 struct — фиксированный 18-байтовый префикс, за которым следует необязательный хвост ссылок на ресурсы:```
+0x00 x i16 [signed — can be negative, see §11.8]
+0x02 y i16
+0x04 meta[14] ────────────────────────────────────────────────────
meta[0..1] w u16 [0x8000 = auto-layout child of a frame]
meta[2..3] h u16
meta[4..6] unknown [placeholder-looking (1,0,0)/(4,0,0); see §11.14]
meta[7] accent-tint capability flag — 4 = tintable (§11.14)
meta[9] DATA SOURCE ID (§16)
meta[10] sub / variant
meta[11..13] max u24 LE [the metric's nominal full-scale value]
+0x12 ref tail [61 …] — absent on imageless rings (0x80/0x81, §11.15)
> ✅ Это объединяет «магические смещения» из §11.5/§11.8/§11.9. Эти разделы находят поля *относительно
> байта `0x61` таблицы кадров* — что просто `+0x12` этой структуры, так что `−18`/`−16` = `x`/`y` и
> **`−5` = `meta[9]`, идентификатор источника**. Те же байты, одна чистая компоновка. Эвристика
> прямого сканирования атрибута `82`, которая систематически ошибалась на единицу, вообще не нужна: читайте `meta[9]` структуры.
> `max` числового поля — это также просто `meta[11..13]` (например, поля дня месяца несут `max = 99`).
**Хвост ссылки** (`61`) — как узел указывает на свои битовые карты:```
+0x00 0x61 [tail type]
+0x01 count u16 [1 = single image · 10/11 = digit atlas · N = pick-list / frame sheet]
+0x03 base u32 [ABSOLUTE FILE OFFSET of the first asset block]
+0x07 count × u16 = the BLOCK SIZE (8 + payload len) of each referenced asset, in order
⚠️ Эти завершающие u16 ранее документировались как «count×id(u16)» / идентификаторы глифов. На самом деле это
размеры блоков: base плюс их нарастающая сумма проходит по записям пула ассетов по одной (✅ проверено
точно для всех 10 записей цифрового атласа). Два следствия:
count−1 размеров являются значимыми; значение последней записи никогда не используется, поэтому
в файлах, встречающихся в дикой природе, там иногда хранится устаревшее значение. Не считайте несоответствие в последней
записи битой ссылкой.0x28 превью — миниатюра магазина/каталога встроена в сам .bin (27 узлов превью
в 15 циферблатах) как 0x08 pvStruct: 5-байтовый префикс плюс тот же хвост ссылки, без x/y.
Полезно для создания галерейного интерфейса без поставки отдельных PNG.
0x02 ✅Соседний элемент 0x02 виджета делает его условным. Без него виджет всегда отрисовывается. Грамматика:```
count u8 , count × ( id u8 , op u8 , val u24 LE signed ) [5 bytes per entry]
`id` — это идентификатор источника данных (§16), включая **синтетические идентификаторы слотов** из §11.13. Операторы, с
подсчётом частоты встречаемости по 15 циферблатам:
| op | значение | встречаемость |
|---|---|---|
| `0x01` | рисовать, если `value == val` | 99 |
| `0x81` | то же, что `0x01` (бит `0x80` установлен — встречается на взаимоисключающих вариантах) | 48 |
| `0x02` | **скрыть**, если `value == val` | 7 |
| `0x03` | рисовать, если `value == val`, где `val` — это **маркер отсутствия данных** (например, HR `1000`) | 13 |
| `0x05` | рисовать, если `value >= val` | 58 |
| `0x06` | рисовать, если `value <= val` | 50 |
| `0x04` | ⚠️ **неизвестно** — 15 вхождений, подтверждённой семантики нет | 15 |
Правило комбинирования (как реализовано в эталонном рендерере, маска `op & 0x7f`): записи с равенством
объединяются через **ИЛИ**, затем записи hide/`>=`/`<=` должны **все** выполняться.
Этот единственный механизм покрывает большую часть изменчивости формата во время выполнения и объясняет структуры, которые выглядят
как дублированные виджеты:
- **Макеты 12ч / 24ч и метрические / имперские** — два набора виджетов, наложенных в одном месте, каждый привязан к
`id 0x73` (флаг единиц измерения) со значением `val` 0 или 1. На циферблате 275 таких пар шесть (12 узлов).
- **Заглушки «нет данных»** — операция `0x03` против контрольного значения, например `id 0x5f, val 1000` (циферблат 275, дважды):
рисуется тире-арт вместо температуры, когда метрика недоступна.
- **Сегментированные подсветки** — парный диапазон `0x05`/`0x06`, например цепочка Metaball, где каждое звено загорается
в своё собственное 5-минутное окно.
- **Альтернативы для слотов усложнений** — привязаны к синтетическим идентификаторам слотов из §11.13.
### 11.13 Настраиваемые слоты усложнений — `0x85` + `0x5f` ✅ (заменяет §11.8)
В §11.8 был сделан вывод, что активная метрика настраиваемого усложнения — это состояние оперативной памяти устройства и не может быть
восстановлена из файла. **Это было неверно** — и меню метрик слота, и его выбор по умолчанию
находятся в `.bin`. Каждый узел `0x85` несёт родственный узел `0x5f`:```
+0x00 slotIndex u8 [0-based position among sibling 0x85 nodes]
+0x01 count u8 [how many metrics this slot offers]
+0x02 activeIdx u8 [index into the list below = the DEFAULT SHOWN METRIC]
+0x03 count × u8 [the metric ids themselves (§16)] … NUL padding
Реальные альтернативы, которые фактически отрисовываются, — это обычные группы 0x68 в других местах дерева, каждая из которых управляется
условием 0x02 (§11.12) на синтетическом id 0x79 + slotIndex — так, варианты слота 0 привязаны к
0x79, слота 1 — к 0x7a и так далее. Чтобы отрисовать слот: прочитайте activeIdx, затем отрисуйте вариант, чьё
условие совпадает с этим индексом.
Измерено на реальных циферблатах:
Два слота по 6 метрик у циферблата 275 дают 12 из его 26 узлов 0x02, ровно как и предсказывалось:
01 79 81 0X 00 00 и 01 7a 81 0X 00 00 для X = 0..5 — шесть альтернатив, привязанных к 0x79 (слот 0),
и шесть к 0x7a (слот 1). (Байты 0x79/0x7a, которые §11.8 назвал «байтом экземпляра», — это и есть эти
id привязки.) Остальные 14 не связаны с этим: 12 на 0x73 (флаг 24ч/единицы метрик, val 0 или 1 — шесть
пар виджетов, переключающихся между 12-часовой и 24-часовой раскладками) и 2 на 0x5f с операцией 0x03 и val = 1000 —
заглушка «нет данных» для температуры.
Всё ещё реальное состояние устройства: что бы пользователь ни выбрал позже в сопутствующем приложении, это переопределяет
activeIdx во время выполнения, поэтому предпросмотр воспроизводит значение по умолчанию из файла, а не обязательно то, что
показывают конкретные часы. imgs[0] узла 0x85 — это заглушка «нажмите, чтобы настроить», которую прошивка рисует только
в собственном режиме редактирования — пропускайте её при предпросмотре обычного отображения времени.
meta[7] == 4 ✅Некоторые циферблаты позволяют пользователю выбрать акцентный цвет прямо на устройстве, и прошивка подставляет его в
растровые изображения виджета во время отрисовки. Переключатель — это один байт: meta[7] структуры (§11.11) —
то есть байт +0x0B тела 0x01 — равный 4 — помечает ресурс(ы) этого виджета как подлежащие тонированию.
.bin должен сохранять исходные пиксели, иначе вы навсегда потеряете выбор пользователя. Применяйте
тонирование только в пути предпросмотра/канваса.⚠️ Не «улучшайте» это до эвристики по цвету — этот путь доказанно тупиковый (задокументировано fmc после того, как они прошли его трудным путём). Интуитивная теория гласит, что помеченные пиксели запечены в каком-то узнаваемом цвете-заглушке, который прошивка заменяет. Это не может работать: тонируемое кольцо циферблата 348 Tumbler и обычные нетонируемые полосы цифр циферблатов 282 Radar Sweep / 291 Vertical запекают ровно тот же самый RGB
(255,72,32)(проверено исчерпывающе, каждый пиксель); а циферблаты 305 Dots (часовая стрелка) и 306 Large Number (цифры) тонируемые, но запечены чисто белым, так что тест по цвету их полностью пропустит. Последовательные уточнения (1 → 4 эталонных цвета плюс белый список ролей виджетов) — все провалились. Читайте флаг.Перекрёстно проверено на реальном устройстве / сопутствующем приложении на 7 циферблатах, выбранных для нагрузки в обе стороны — 349 Theatre, 376 Digits time, 305 Dots, 306 Large Number, 304 Elaborate 2 — все предлагают настройку акцента и все имеют виджеты с
meta[7]==4; 316 Trailing (красноватая стрелка, без настройки), 312 Disc и 295 Vortex не предлагают её и не имеют ни одного помеченного виджета.
meta[4..6] находится прямо рядом с флагом и выглядит так, будто может кодировать цвет в некоторых структурах
(похожий на настоящий RGB с хвостом f1=1,f2=255, против заглушки (1,0,0)/(4,0,0) у помеченных структур).
Он не коррелирует с возможностью акцента. ⚠️ Не разрешено; игнорируйте его.
0x80/0x5a (процедурные) и 0x81/0x5b (с обрезкой по изображению) ✅Обе разновидности колец объединяют короткую структуру (x, y, meta с id источника — обычно вообще без
хвоста ref) с родственным узлом описания дуги. §11.5 задокументировал только «0x5b: max u16 @+4», что является
младшей половиной max i32 и упускает геометрию дуги. Полная запись:```
+0x00 min i32 LE [always 0 in the corpus]
+0x04 max i32 LE [100 in the corpus, except dial 332 = 60]
+0x08 start i16 LE [sweep start, units of 0.1° — SIGNED]
+0x0a end i16 LE [sweep end, units of 0.1° — SIGNED]
+0x0c width u16 LE [stroke width in px]
+0x0e radius u16 LE [0x5a ONLY — 0x81 takes its radius from the clipped image]
+0x0e / +0x10 trailer 01 00 kk ⚠️ unresolved (see below)
Сопряжение жёсткое: `0x80` всегда несёт ровно `0x01` + **19-байтный** `0x5a`, `0x81` всегда ровно
`0x01` + **17-байтный** `0x5b` (✅ 26/26 колец на 15 циферблатах). ⚠️ Но **обрезанный по изображению `0x81`
доминирует** — 25 из этих 26. Процедурный вариант `0x80`/`0x5a` появился **один раз** (циферблат 273), так что его
поле `radius` и 19-байтная раскладка опираются на единственный образец; относитесь с подозрением, пока не увидите снова.
`frac = clamp((value − min) / (max − min), 0, 1)`, и заполненная дуга идёт от `start` к `end`.
Нулевой угол — на **3 часах**, положительное направление — по часовой стрелке. Измеренные примеры:
| циферблат | тег | min..max | start → end | ширина | radius |
|---|---|---|---|---|---|
| 273 Activity Mood | `0x5a` | 0..100 | **−102,8° → 102,8°** | 42 | 222 |
| 273 Activity Mood | `0x5b` | 0..100 | 270,0° → 90,0° | 80 | (изображение) |
| 276 Dichotomy | `0x5b` | 0..100 | 60,0° → −120,0° | 23 | (изображение) |
| 304 Elaborate 2 | `0x5b` | 0..100 | −2,0° → 358,0° | 24 / 80 | (изображение) |
| 366 Combo | `0x5b` | 0..100 | 0,0° → 270,0° | 18 | (изображение) |
| 368 Function | `0x5b` | 0..100 | 0,0° → 360,0° | 20 | (изображение) |
> ⚠️ **Это исправляет предположение «по часовой стрелке от 12 часов»** из §11.5. Это лишь частный
> случай `start = 0, end = 3600` (полный оборот, где соглашение ненаблюдаемо). Реальные циферблаты используют
> **частичные шкалы** (веер ±102,8° у 273, трёхчетвертное кольцо 270° у 366) и **отрицательные развёртки**
> (60° → −120° у 276), поэтому рендерер, который всегда рисует полный круг сверху, отображает их неверно.
> 🟡 Точное соглашение о нулевом угле и правило направления взяты из рендерера fmc, перепроверены
> по этим значениям на диске — не подтверждены попиксельно на устройстве этим репозиторием.
> Обратите внимание: `frac = value/max` и результат обрезки сектора `0x81` из §11.5 **были** проверены (циферблат 322).
⚠️ **Нерешено: 3-байтный хвост `01 00 kk`.** `kk` принимает правдоподобные значения (104, 152, 216,
232, 248), и очевидная гипотеза — радиус; **проверено и опровергнуто**: циферблат 366 использует `kk = 104`
и для кольца 82×82, и для кольца 166×166, а циферблат 273 использует `kk = 232` и для кольца 440×440, и для кольца 284×284.
Не радиус, не диаметр. Возможно, непрозрачность/стиль. Передавайте как есть.
🟡 **Сообщённая ловушка, здесь не проверена:** в рендерере fmc число `0x60`, чей исходный id совпадает с id
**любого** кольца на том же экране, отображает `round(frac × 100)` вместо сырого значения — так,
число пульса рядом с кольцом пульса показывает `36` вместо `71 уд/мин`. Их документированный
обходной путь — разместить кольцо и число на двух **псевдонимных** id одной метрики (шаги
`0x19`/`0x26`/`0x49`, калории `0x1c`/`0x1e`/`0x48`). Является ли это поведением прошивки или особенностью
их рендерера — **не установлено**; стоит проверить вживую, прежде чем проектировать под это.
---
## 12. Детали массовой передачи и OTA
Таблица передачи — в §6. Дополнительные подтверждённые пункты:
- **AGPS/EPO** ✅: первый записанный чанк начинается с ASCII-заголовка `000000010000…`. Полный
цикл init → `[A05F ↔ 905F]×N` → finish наблюдался в эфире (~892 чанка).
- **OTA прошивки** (`9040`–`9042`, finish `9041`) 🔎: структура сопоставлена; полезная нагрузка INIT2 = байты версии
(например, `0b 00 00 39` = 11.0.0.57). **Не проверено в полевых условиях** (приложение отключает обновление прошивки здесь). Образы
прошивки, по-видимому, **без подписи — целостность только CRC32** (асимметричной подписи в RE не наблюдалось).
- ⚠️ Поскольку OTA и `FACTORY_RESET (009A 0001)` используют аутентифицированную сессию, одной валидной BLE-
аутентификации достаточно, чтобы стереть или (в принципе) «окирпичить» часы. Обращайтесь осторожно.
---
## 13. Датчики
✅ Аппаратное обеспечение, доступное через BLE:
- **Оптический PPG** — пульс (ручной/авто/тренировка/покой), SpO₂ и стресс на основе ВСР.
- **3-осевой акселерометр** — шаги, дистанция, калории, стадии сна, подъём запястья, каденс.
- **GNSS/GPS** (с поддержкой AGPS) — трек тренировки (`WORKOUT_GPS`) и отправка местоположения (`GPS_PUSH`).
**Нет** барометра/альтиметра, компаса, гироскопа или датчика температуры кожи/тела. Внутренний
термистор NTC (температура платы/батареи) существует, но читается **только** через AT-канал
(`AT GETNTCTEMP`, §14) — поток истории температуры кожи `0155` на этом SKU пуст.
**Перехват виджетов данных** (настоящего API усложнений/привязки данных нет — см. §11.5): существующие
текстовые поля часов можно перепрофилировать для отображения внешних данных с одного взгляда. Проверено ✅: строка
**города** погоды (`WEATHER_SET_1`, например, `"BRA 2x1 ARG"` появилась на виджете) и поля музыки
**трек/исполнитель**; список **контактов** (20 × имя[32]+номер[25]) работает как прокручиваемая панель
данных. Всё это push-уведомления, а не постоянные усложнения.
---
## 14. AT-канал завода / оболочки (`77d4ff01` / `77d4ff02`)
Отдельный текстовый AT-канал, независимый от фреймового протокола. ✅ проверено вживую:
- **Чтение:** `AT GETSECRET` (16-байтный секрет сопряжения), `GETVERSION`, `GETSN`, `GETNAME`, `GETPID`,
`GETBATLV` (сырые мВ, например, `3853mv`), `GETGSENSOR` (сырой акселерометр в g, `X=… Y=… Z=…`),
`GETNTCTEMP` (°C, внутренний NTC).
- **Запись / действие:** `AT SETMOTOR=1` (вибрация мотора), `SETHR/SETHRV/SETSPO2=…` (тестовая
инъекция датчиков), `SETLCDSWITCH/SETGPSSWITCH/SETKEYSWITCH`.
Ответы заканчиваются на `,OK`. Команды `SET*` обычно выполняются, но могут не возвращать `,OK` по BLE — проверяйте
в каждом конкретном случае.
---
## 15. Функции, ограниченные прошивкой / недоступные (🔎 RE прошивки)
Некоторые функции присутствуют в прошивке, но отключены SKU/регионом и **недоступны с телефона/BLE** —
для них нужна модификация прошивки, что выходит за рамки здесь:
- **ChatGPT голос** — шлюз = id функции `ux2sys` `0x9e`, заполняется из NVRAM/EFUSE/региона при загрузке; на
этом SKU флаг поддержки `908b = 00`. Не влияет ни телефон, ни аккаунт, ни BLE (подтверждено
экспериментом + RE). Приложение — лишь ретранслятор; аудио идёт телефон → облако Nothing.
- **Кровяное давление** — полная подсистема существует в прошивке, отключена SKU/регионом.
- **Alipay / NFC-платежи** — полный UI присутствует, только для китайского SKU.
- **Отсутствует в аппаратном обеспечении/прошивке:** ЭКГ, SOS/экстренная помощь, универсальный NFC.
---
## 16. Id источников данных / геттеров усложнений
Не нужны для создания BLE-клиента, но **критичны для создания или рендеринга циферблата**: это значение
`meta[9]` (§11.11), которое привязывает виджет к живым данным, `id` условия видимости (§11.12) и
записи меню метрик слота (§11.13). Прошивка разрешает его через **таблицу диспетчеризации геттеров из 142 записей**
по адресу `0x101f371c`, каждая запись вызывает `ux2sys_get(type)` (🔎 RE прошивки).
### Время / дата — ✅ хорошо установлено
| id | значение | id | значение |
|---|---|---|---|
| `0x01` | час (12/24ч по настройке устройства) | `0x0f`, `0x12` | секунда (плавная) |
| `0x04` | час (24ч) | `0x10`, `0x11` | десятки / единицы секунд |
| `0x07` | час (принудительно 24ч) | `0x71`, `0x72` | секунда (тикающая / угол стрелки) |
| `0x02`, `0x03` | десятки / единицы часа-12ч | `0x13` | флаг AM/PM (0 = AM, 1 = PM) |
| `0x05`, `0x06`, `0x08`, `0x09` | десятки / единицы часа | `0x15`, `0x16` | месяц |
| `0x0a`, `0x70` | угол часовой стрелки | `0x17` | день месяца |
| `0x0b` | минута | `0x18` | день недели (0 = понедельник ⚠️) |
| `0x0c`, `0x0d` | десятки / единицы минут | `0x0e`, `0x71` | угол минутной стрелки |
Id десятков/единиц рисуют **одну цифру** — виджет, привязанный к одному из них, отображает один глиф, а не всё
значение (циферблат 284 Square). Id углов стрелок (§11.5): `0x0a`/`0x70` час = `ч·30° + м·0,5°`,
`0x0e`/`0x71` минута = `м·6° + с·0,1°`, `0x12`/`0x72` секунда = `с·6°` (✅ подтверждено дизассемблированием
геттеров, запасной вариант RTC 10:10:30).
### Здоровье / датчики / погода — ⚠️ спорно, читайте колонку доказательств
| id | значение (лучшее текущее прочтение) | доказательства |
|---|---|---|
| `0x19` | **шаги** | в меню слотов 368 рядом с `0x1a`; совпадает с `parse.ts` этого репозитория |
| `0x1a` | **пульс** | в меню слотов 368 рядом с `0x5f` и `0x19` |
| `0x1c` | калории | меню слотов, иконка пламени в сопутствующем приложении |
| `0x1e` | калории (псевдоним) | корпус |
| `0x22` / `0x23` | дистанция км / мили (целая часть) | корпус |
| `0x74` / `0x75` | дистанция км / мили (дробная часть) | корпус |
| `0x76` | дистанция (форма слота) | меню слотов, иконка дороги |
| `0x24` | **заряд батареи %** | меню слотов, иконка молнии |
| `0x30` | заряд батареи % | корпус |
| `0x36` / `0x5f` | температура | `0x5f` = меню слотов, иконка облако-солнце; 361 TempoG привязывает обычное число |
| `0x48` | периоды активности (часы стояния) | меню слотов, иконка стоящей фигуры |
| `0x8b` | ИКВ | меню слотов |
| `0x73` | флаг 24ч / метрических единиц | корпус |
| `0x25`–`0x27`, `0x49`, `0x6c`, `0x6f` | % цели / псевдонимы слотов шагов и калорий | корпус |
| `0x6a` | ⚠️ неопознанная метрика слота | появляется в 4 меню слотов |
| `0x79 + slotIndex` | **синтетический** — не метрика; id выбора слота (§11.13) | ✅ §11.13 |
> ⚠️ **Три таблицы в этом репозитории расходились; эта — согласование.** Более ранние редакции §16
> и §11.5 читали `0x19` как пульс, `0x1b` как заряд батареи, `0x24` как температуру и `0x36` как шаги —
> и `wfweb/src/codec/mock.ts` до сих пор кодирует это прочтение, тогда как `wfweb/src/codec/parse.ts` кодирует
> другое (`0x19` шаги, `0x24` % цели, `0x48` периоды активности, `0x1a` погода). Таблица выше следует
> лучше обоснованному прочтению (метки откалиброваны fmc по иконкам меню слотов виджетов самого сопутствующего
> приложения, см. §Источники). **Код ещё не изменён — `mock.ts` и `parse.ts` по-прежнему не согласованы
> друг с другом и с этой таблицей.** Относитесь к меткам id здоровья как к ⚠️, пока кто-то не привяжет
> поле к каждому id и не прочитает часы.
>
> Самое сильное единичное доказательство — **меню слотов циферблата 368 Function** (§11.13), которое предлагает
> `0x5f 0x1c 0x19 0x48 0x24 0x76 0x1a 0x8b` как **восемь различных выбираемых пользователем метрик** в одном меню.
> Какими бы ни были метки, никакие две из этих восьми не могут быть одной метрикой — что исключает
> одновременное `0x19` = `0x1a` = пульс и `0x24` = `0x5f` = температура.
Кольцевые/дуговые усложнения с **листом кадров** индексируют предварительно отрендеренный кадр (например, 50 % = кадр 50 из 100),
встроенный в `.bin`, который вы отправляете (§11.5), так что внешний RES-пакет не нужен; кольца без изображений
рисуются из спецификации дуги (§11.15).
---
### Быстрые карточки (домашние плитки) — `QUICK_CARD (906D)` ✅
Домашние плитки часов. Телефон выбирает только **какие** плитки показывать и в **каком порядке** — плитки
рендерит прошивка (канала контента нет). Первый байт полезной нагрузки = подкоманда: `00` = GET, `01` =
SET; **обе используют `0x906D`** (`0x906C` указан, но не используется — запрос к нему истекает по таймауту). Ответ = `A06D`.
> 🛑 Отправка выдуманного `assemblyId` **стирает экраны часов** (они принимают список, не могут сопоставить
> id, ничего не показывают). Отправляйте только те id, которые вы **считали** через GET; восстановление — через официальное приложение или
> сброс к заводским настройкам.
**Ответ GET** ✅: `status(1) ‖ 00 ‖ N(1) ‖ N × группа`, группа = `tag=01 ‖ K(1) ‖ K×(assemblyId, sportId)`.
Реальный кадр: `01 00 04 01 02 5d00 6100 01 03 1900 2e00 2300 01 03 5c00 0400 5a02 01 03 4800 5100 5300`
= 4 экрана / 11 карточек (`5a02` = карточка Sport, sportId 2).
**Слоты:** на каждом экране **4 слота**. Тип карточки задаёт её размер — `circular`/`square` = 1 слот,
`rectangle` = 2 слота. Проверка — чистая арифметика слотов (Σ ≤ 4 на экран); взаимоисключающих
карточек нет. `sportId` равен `0`, кроме карточек Sport (87–91). Id `64` и `95` не существуют.
**Каталог `assemblyId`** (каждый логический тип = непрерывный диапазон из 6 вариантов стиля `_0`..`_5`;
`0` = пустой слот):
| дес. | карточка | дес. | карточка |
|----|----|----|----|
| 0 | пустой слот | 49–53,97–98 | Погода |
| 1–6 | Шаги | 54–58 | Таймер |
| 7–12 | Калории | 59–62 | Дыхание |
| 13–18 | Периоды активности | 63,65–67 | Секундомер |
| 19–24 | Умеренная активность | 68–71 | Батарея |
| 25–30 | Пульс | 72–76 | Недавние |
| 31–36 | SpO₂ | 77–81 | Контакты |
| 37–42 | Стресс | 82–86 | Циферблат / телефон |
| 43–48 | Сон | 87–91 | Спорт (`sportId` ≠ 0) |
| 92 | Музыка | 93/94/96 | Запись активности / PAI / Цикл |
---
## Приложение A. Каталог стоковых циферблатов (id → имя)
§11 ссылается на циферблаты по числовому id (275 SlopeTime, 322 Glare 2, 357 Silhouette, …). Id
`273`–`376` — это магазинные/стоковые лица; id — это то, что `DIAL_COMMAND (9055/a055)` сообщает как активный и
что `9075` принимает как `old_id` (§11.4). 100 из 103 известных id названы ниже; остальные —
заглушки ROM (§11.4). Имена и группировка — как показывает официальное сопутствующее приложение.
- **По умолчанию** (6) — `273` Activity Mood · `274` Sun Circle · `275` SlopeTime · `276` Dichotomy · `277` Prismatic Time · `280` Multifunction
- **Аналоговые** (34) — `286` Sundial · `287` Simple Dial · `292` City · `294` Sudoku · `305` Dots · `306` Large Number · `309` Gradient · `310` Glare · `311` Bold · `313` Classical · `314` Fragment · `315` Infinite · `316` Trailing · `322` Glare 2 · `326` Chrono Master · `327` Digit Max · `328` Coherent · `329` Wheel · `330` Zenith · `331` Intersection · `335` Time Phase · `336` Energetic · `338` Chronos · `341` Dual View · `346` Time Windmill · `347` Large Panel · `349` Theatre · `352` Elegant Sweep · `360` Explorer · `364` SportPulse · `370` Hemisphere · `371` ActiveTrio · `372` Time Wheel · `373` Traditional Pointer
- **Цифровые** (41) — `281` Metaball · `282` Radar Sweep · `283` Radio · `285` Widgets · `288` Type · `289` Rotate · `290` Gradual · `291` Vertical · `293` Stairs · `296` Ladder · `297` Ray · `298` Eclectic · `299` Echo · `300` Mono Dial · `301` Orbit · `302` Calendar · `303` Space · `307` Sprung · `308` Sundial 2 · `319` One Line · `320` Orienteer · `321` Revolution · `323` Dash · `324` Finesse · `325` Metric · `333` Circularity · `334` Globe of Time · `337` Time Finder · `339` Suprematism · `340` Sport Mode · `345` Time Dot · `350` Timeline · `351` Cyclopes · `353` Dual phase · `357` Silhouette · `359` Ring data · `361` TempoG · `362` Steady · `365` Elegance · `369` Solar System · `376` Digits time
- **Многофункциональные** (10) — `304` Elaborate 2 · `344` InfoMeter · `348` Tumbler · `354` Dual · `363` Vintage · `366` Combo · `367` Complex Figure · `368` Function · `374` Cirquary · `375` InfoHub
- **Креативные** (8) — `284` Square · `312` Disc · `317` Disc 2 · `318` Dominos · `332` Flux · `342` Perfect Match · `343` Progress Day · `358` Asteroid
- **Дивали** (1) — `295` Vortex
---
## Источники
Байтовые раскладки выше реконструированы из прошивки (1.0.0.73), официального APK (3.5.7) и
расшифрованных живых захватов с реального устройства. Эталонная реализация этого проекта находится в
`core-rust/src/{commands,frame,crypto,health,session}.rs` (Rust), редакторе TypeScript `wfweb/` и
Python-инструментах `cmftool/` (`pair.py`, `session.py`, `wf_codec.py`, `upload_custom.py`, …).
**Включённые независимые работы.** [freethinkel/fmc](https://github.com/freethinkel/fmc) — это
редактор циферблатов SvelteKit + маркетплейс для тех же часов — независимо провёл обратную разработку
формата `.bin` на корпусе из ~100 лиц и достиг нескольких результатов, которых в этом документе не было или которые были неверны.
Их `docs/cmf-protocol.md`, `src/lib/modules/editor/lib/{wf,render}.ts` и
`src/lib/modules/device/lib/ble.ts` стоит прочитать напрямую. Принятые находки, каждая повторно проверена
по байтам циферблатов перед включением сюда:
| Находка | Где | Статус здесь |
|---|---|---|
| Оба заголовочных слова — CRC32 (нестандартный вариант) | §11.4 | ✅ повторно проверено 9/9 циферблатов; **исправляет** «нет блокирующей контрольной суммы» |
| Условия видимости, тег `0x02` | §11.12 | ✅ повторно проверено; было недокументировано |
| Список метрик слота + активный `activeIdx` по умолчанию, `0x79 + slotIndex` | §11.13 | ✅ повторно проверено на 275/368/273/304; **заменяет** §11.8 |
| Флаг возможности акцентного оттенка `meta[7] == 4` | §11.14 | ✅ распространённость повторно проверена; было недокументировано |
| Полная спецификация дуги (углы развёртки, ширина, радиус) | §11.15 | ✅ повторно проверено; **исправляет** «`max` u16 `@+4`» |
| Хвостовые `u16` ref — размеры блоков, а не id глифов | §11.11 | ✅ повторно проверено точно на атласе из 10 глифов |
| 36-байтный футер; вариант магического байта `0x02`; `0x86` = 64 Б; встроенный предпросмотр `0x28` | §11.4, §11.11 | ✅ повторно проверено |
| Метки id здоровья/погоды откалиброваны по меню слотов сопутствующего приложения | §16 | ⚠️ принято как лучшее прочтение; конфликты отмечены |
| UUID сервиса оболочки — `77d4e67c-…`, и область видимости `optionalServices` Web Bluetooth | §1 | ⚠️ отчёт с одного устройства, здесь не повторно проверено |
| Число с id, совпадающим с кольцом, отображает процент | §11.15 | 🟡 сообщено, **не** проверено здесь |
| Каталог стоковых циферблатов id → имя | Приложение A | ✅ принято как есть |
| Назначение | Сервис | Характеристика | Свойства |
|---|
| Запись команд | 0000fff0-0000-1000-8000-00805f9b34fb | 0000fff2-… | Write |
| Уведомления команд | 0000fff0-… | 0000fff1-… | Notify |
| Запись оболочки (AT) | — | 77d4ff01-2fe2-2334-0d35-9ccd078f529c | Write |
| Уведомления оболочки (AT) | — | 77d4ff02-… | Notify |
| Запись массовых данных | — | 02f00000-0000-0000-0000-00000000ffe1 | Write |
| Уведомления массовых данных | — | 02f00000-…ffe2 | Notify |
| Имя | cmd1,cmd2 |
|---|
| TIME | FFFF 8004 |
| FIRMWARE_VERSION_GET / _RET | FFFF 8006 / FFFF 0006 |
| SERIAL_NUMBER_GET / _RET | 00DE 0002 / 00DE 0001 |
| BATTERY | 005C 0001 |
| TRIGGER_SYNC | 005C 0002 |
| USER_INFO_SET / _RET 🔎✅ | 0095 0001 / 0095 0003 |
| FACTORY_RESET | 009A 0001 |
| DEVICE_REBOOT 🔎 | FFFF 9080 |
| RESOLUTION_GET 🔎 (→ 466×360) | FFFF 907F |
| GPS_PUSH / _RET | FFFF 906A / FFFF A06A |
| UNBIND_SET / _RET | FFFF 907A / FFFF A07A |
| Имя | cmd1,cmd2 |
|---|
| AUTH_PHONE_NAME | FFFF 8049 |
| AUTH_WATCH_MAC | FFFF 0049 |
| AUTH_PAIR_REQUEST / _REPLY | FFFF 8047 / FFFF 0048 |
| AUTH_NONCE_REQUEST / _REPLY | FFFF 804B / FFFF 004C |
| AUTHENTICATED_CONFIRM_REQUEST / _REPLY | FFFF 804D / FFFF 0004 |
| AUTH_FAILED | FFFF A061 |
| Имя | cmd1,cmd2 |
|---|
| APP_NOTIFICATION | 0065 0001 |
| INCOMING_CALL ⚠️ | 0064 0001 |
| CALL_REMINDER_REQUEST / _RESPONSE | FFFF 9066 / FFFF A066 |
| FIND_PHONE | 005B 0001 |
| FIND_WATCH | 005D 0001 |
| FIND_WATCH_TOGGLE | FFFF 9069 |
| SMS_MESSAGE_PUSH / _RET | FFFF 906E / FFFF A06E |
| QUICK_REPLY_SET / _RET | FFFF 9073 / FFFF A073 |
| Имя | cmd1,cmd2 |
|---|
| ALARMS_SET / _GET | 0063 0001 / 0063 0002 |
| CONTACTS_SET / _GET | 00D5 0001 / 00D5 0002 |
| STANDING_REMINDER_SET / _GET | 0060 0001 / 0060 0002 |
| WATER_REMINDER_SET / _GET | 0061 0001 / 0061 0002 |
| TASK_REMINDER_SET / _RET ⚠️ | FFFF 9072 / FFFF A072 |
| Имя | cmd1,cmd2 |
|---|
| GOALS_SET / _ACK | 005E 0001 / 005E 0003 |
| UNIT_LENGTH / _ACK | FFFF 9067 / FFFF A067 |
| UNIT_TEMPERATURE / _ACK | FFFF 9068 / FFFF A068 |
| TIME_FORMAT / _ACK | 005F 0001 / 005F 0003 |
| WAKE_ON_WRIST_RAISE / _GET / _ACK | 0062 0001 / 0062 0002 / 0062 0003 |
| LANGUAGE_SET / _RET | FFFF 9058 / FFFF A06B |
| HEART_MONITORING_ENABLED_SET / _GET | 009B 0001 / 009B 0002 |
| HEART_MONITORING_ALERTS | FFFF 9059 |
| DO_NOT_DISTURB / _GET | 0099 0001 / 0099 0002 |
| SPORTS_SET / _GET | 00DC 0001 / 00DC 0002 |
| SPORT_LINKAGE_SET / _RET | FFFF 9076 / FFFF A076 |
| SPORT_DATA_SYNC 🔎 (живой HR/калории/шаги) | FFFF 9078 / FFFF A078 |
| FEMALE_CYCLE_SET / _RET | FFFF 9071 / FFFF A071 |
| SLEEP_CONFIG_SET / _RET (целевой минимум) | FFFF 9074 / FFFF A074 |
| WORLD_CLOCK_GET | FFFF 906F |
| WORLD_CLOCK_DST_SET / _RET | FFFF 9083 / FFFF A083 |
| VITALITY_GET / _RET | FFFF 9079 / FFFF A079 |
| VITALITY_SW_SET / _RET | FFFF 9070 / FFFF A070 |
| Имя | cmd1,cmd2 |
|---|
| DIAL_COMMAND_SET / _RET (список/переупорядочивание/выбор) | FFFF 9055 / FFFF A055 |
| DIAL_CONFIG_SET / _RET | FFFF 9075 / FFFF A075 |
| CHANGE_DIAL (⚠️ неактивен на 1.0.0.73 — не использовать) | 009F 0001 |
| QUICK_CARD_SET/GET / _RET (оба на 906D) | FFFF 906D / FFFF A06D |
| Имя | cmd1,cmd2 |
|---|
| ACTIVITY_FETCH_1 / _2 | FFFF 8005 / FFFF 9057 |
| ACTIVITY_FETCH_ACK_1 / _2 | FFFF 0005 / FFFF A057 |
| ACTIVITY_DATA | 0056 0001 |
| SLEEP_DATA / _GET | 0058 0001 / 0058 0002 |
| SPO2 | 0055 0001 |
| STRESS | 009D 0001 |
| HEART_RATE_MANUAL_AUTO | 0053 0001 |
| HEART_RATE_RESTING | 00DA 0001 |
| HEART_RATE_WORKOUT | 00E0 0001 |
| SKIN_TEMP_HISTORY 🔎 (пусто на этом SKU) | 0155 0001 / 0155 0002 |
| WORKOUT_SUMMARY / _V3 | 0057 0001 / 0160 0001 |
| WORKOUT_GPS | FFFF A05A |
| Домен | INIT1 req/reply | INIT2 req/reply | CHUNK req/write | FINISH ack1/ack2 |
|---|
| Циферблат (фото) | 8052/0052 | 9063/A063 | A064/9064 | A065/9065 |
| Циферблат (структурированный/переключение) | 8052/0052 | 9075/A075 | A064/9064 | A065/9065 |
| Прошивка | 9052/A052 | 9040/A040 | A042/9042 | A041/9041 |
| AGPS/EPO | 905E/A05E | — | A05F/905F | A060/9060 |
| Смещение | Размер | Поле |
|---|
| 0 | 4 | метка времени (эпоха, с) |
| 4 | 4 | шаги |
| 8 | 4 | дистанция (м) |
| 12 | 4 | калории |
| 16 | 16 | зарезервировано (наблюдалось 0) |
| Смещение | Размер | Поле |
|---|
| 0 | 4 | начало_сессии (эпоха, UTC) |
| 4 | 4 | пробуждение (эпоха, UTC) |
| 8 | 2 | всего_глубокий_с |
| 10 | 2 | всего_основной_с |
| 12 | 2 | всего_БДГ_с |
| 14 | 2 | всего_бодрств_с |
| 16 | 2 | ⚠️ [неопределённо] (id/оценка сессии? наблюдаемые значения не совпадают с суммами записей) |
ac 49 1f 0100D5 0001) ✅: N × 57 байт = name(32) ‖ phone(25). Интерфейс часов показывает до 20.0063 0001) ✅ — исправляет Gadgetbridge (который помещал метку в конец,
с заполнением 0xff — неверно). 40 байт на будильник, big-endian:
secondsOfDay(i32) ‖ index(u8) ‖ enabled(u8) ‖ repetition-bitmask(u8) ‖ flag(u8) ‖ label[32] UTF-8.
Метка находится на смещении 8 и отображается на часах. repetition = битовая маска дней недели (0 = одноразовый);
flag ⚠️ [неопределёнен] (маркер одноразового?). Пример (13:30, idx 2): 0000bdd8 02 01 15 00 "Alarm…".005E 0001) ✅ — официальное приложение и эталонная реализация используют
10-байтовый, big-endian DailyTargetBean v1: steps(u32 BE) ‖ distance_m(u32 BE) ‖ calories_kcal(u16 BE). (Это форма Gadgetbridge; более ранние сообщения, что часы «игнорировали»
его, были ошибкой расшифровки устаревшей сессии, а не проблемой payload.) 🔎 РЕ прошивки также показывает более длинный
29-байтовый расширенный вариант (добавляет sleep_min/exercise_min/stand_h + 6 флагов включения, все u32
BE после префикса flag(u16 LE), с принудительными диапазонами: шаги 2000–30000, дист 1000–99000, кал
100–5000, сон 360–720, упражнения 30–90, стояние 6–16) — не путь по умолчанию приложения; предпочитайте
10-байтовую форму, если не нужны дополнительные цели.0060/0061 0001) ✅: 11 байт:
enabled(1) ‖ threshold_min(u16 LE) ‖ dndStart(u32 LE) ‖ dndEnd(u32 LE). Обратите внимание, что «активное окно
08:00–22:00», показанное в интерфейсе, — это фиксированный дефолт прошивки и не передаётся в payload.00DC 0001) ✅: count(1) = 36 слотов ‖ activityTypeCode[36] (активные коды, затем заполнение 00).
Выбирает, какие виды спорта появляются в меню тренировок часов.009B 0001) ✅: байт kind — 01 = HR 24/7, 02 = SpO₂,
04 = стресс (измеряется каждые 30 мин).FFFF 9059) ✅: отключено = 00; включено =
01 ‖ hrLow ‖ hrHigh ‖ sportHrHigh ‖ spo2Low ‖ 00 00 00 00 (граница 0/255 = «без лимита»).FFFF 9071) ✅: 01 ‖ predictionOpen ‖ notifySwitch ‖ cycleStartSwitch ‖ cycleStartNotifyBefore ‖ ovulationStartSwitch ‖ ovulationStartNotifyBefore ‖ fertileStartSwitch ‖ fertileStartNotifyBefore ‖ period(1) ‖ cyclePeriod(1) ‖ cycleStartDate(u32) ‖ markStart(u32) ‖ markEnd(u32) (захвачено: period=5, cyclePeriod=0x1c=28).FFFF 9073) ✅: TLV — count(1) ‖ total(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]…
(захвачено и расшифровано 7 ответов по умолчанию).FFFF 906F) ✅: отправляет числовые ID городов, а не названия (01 ‖ count ‖ cityId(2 BE)…);
часы сопоставляют id из внутренней таблицы. Конфигурация DST FFFF 9083 =
count ‖ [id(u16 LE) ‖ dst(u16 LE) ‖ start(u32 LE) ‖ end(u32 LE)]….FFFF 905C, 131 Б) ✅: state(1: 0=нет/1=пауза/2=играет) ‖ volume(1) ‖ volumeMax(1) ‖ track(64) ‖ artist(64). Часы также отправляют обратно MUSIC_BUTTON (A05D).FFFF 906B, 199 Б) ✅ — используйте этот: 7×9-байтовых дней + 24×2-байтовых часов +
город(32) + 7×8-байтовых восход/закат (LE). Температуры кодируются как (temp_c + 100) & 0xFF.
⚠️ Тот же payload, отправленный на WEATHER_SET_2 (0066 0001), не обновляет виджет погоды на
Pro 2 — всегда используйте 906B. (Строка города также является доказанным вектором перехвата данных — см. §13.)005D 0001) ✅: payload 0x01 → часы звонят/вибрируют (+ ACK 005D 0003).FFFF 906A) ✅ — big-endian, сначала долгота: 16 байт
ts(u32 BE) ‖ lon×1e7(i32 BE) ‖ lat×1e7(i32 BE) ‖ 00 00. Проверено на реальном местоположении.FFFF A05A) ✅ — little-endian, сначала долгота: 12 байт
ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32).FFFF 8004): см. §7.| cf | bpp | raster (after LZ4) | use |
|---|
| 4 | 2 | RGB565-LE | opaque background (FULL/THUMB) |
| 5 | 3 | RGB565-LE (2 B) + alpha (1 B) per px | anti-aliased sprites (glyphs, hands, icons) |
| 13 (0x0d) | 0.5 | 4-bit alpha mask; firmware tints at runtime | digit-glyph atlas |
| 24 (0x18) | 4 | RGBA8888 | full-colour layers (incl. the always-on aodImage) |
| 1 | — | JPEG/JFIF (ff d8 ff), extract with any decoder | rare animation frames |
0avalidate_rejects_bad_containers0x61NotEnvelope0aChildOverflow:61 0a 00−18/−160a.bin0x68
, сложенных в одной точке (x,y), каждый рисуется только при совпадении условия видимости — это
было верно. Но список метрик для слота (275-й 0x1c/0x6a/0x48/0x24/0x19/0x76) — это именно
список метрик, а не "идентификаторы опций/стилей"; индекс активного по умолчанию — это байт в файле; а
байт 0x79/0x7a — не "байт экземпляра", а идентификатор, по которому ключуются альтернативы
(0x79 + slotIndex). Статический предпросмотр может воспроизвести значение по умолчанию из файла. См. §11.13.(446,0), виден на 275/302/325/365/375)
— это слоты, которые прошивка не рисует в представлении по умолчанию — их значение даже не помещается
перед краем холста. Считайте их скрытыми в предпросмотре.0x220x22aod0x22@69,2090x60 img_number (cnt=10) — источник на −5, прямое смещение на один. ✅ Та же ошибка смещения на один,
что и в §11.8, но для нечасовых чисел: дата "Gradient" находилась в (203,80) по центру сверху с источником
0x17, но прямое сканирование 82 захватывало геттер угла соседнего указателя (0x0a) и
позицию указателя → число отображалось в месте указателя с ложным источником. Исправление: для
61 0a 00 img_number в обёртке 0x60 доверяйте −5/−18/−16, когда прямой источник
невозможен для числа (источник-0 или геттер угла указателя 0x0a/0e/12/70/71/72) и
позиция −18/−16 допустима и ненулевая (защита от нуля пропускает цифры-дети групп с relX=0).0x17 = дата (день месяца), 0x24 = температура — различны. Циферблат 340 использует оба (0x17
"Jun 09" и отдельную 0x24 температуру), поэтому 0x17 — это дата, а не температура. Циферблат, на котором часы показывают
температуру в слоте 0x17, — это пользовательское усложнение (состояние устройства), а не значение по умолчанию из файла.| тег | роль | контейнер? | тело |
|---|
0x20 | корень сцены (обёртка тела, не отрисовываемый элемент) | ✅ | дети |
0x21 | обычный экран | ✅ | дети |
0x22 | экран AOD (§11.9) | ✅ | дети |
0x28 | встроенная миниатюра предпросмотра каталога | ✅ | один дочерний 0x08 |
0x68 | группа / контейнер автопозиционирования | ✅ | кадр 0x48 + дети |
0x30 | статическое изображение или выбор по значению из N изображений | ✅ | 0x01 (+0x02) |
0x60 | живое числовое считывание (полоса цифр) | ✅ | 0x01 + 0x40 (+0x02) |
0x70 | вращающаяся стрелка | ✅ | 0x01 + точка поворота 0x05 |
0x80 | кольцо прогресса, процедурное | ✅ | 0x01 + 0x5a (§11.15) |
0x81 | кольцо прогресса, с обрезкой по изображению | ✅ | 0x01 + 0x5b (§11.15) |
0x85 | назначаемый пользователем слот усложнения | ✅ | 0x01 + 0x5f (§11.13) |
0x01 | struct — геометрия + атрибуты (ниже) | — | x,y,meta[14] + хвост ссылок |
0x02 | условие видимости (§11.12) | — | список условий |
0x05 | точка поворота — flag u8, pivotX u16, pivotY u16 | — | 5 Б |
0x08 | pvStruct — prefix[5] + хвост ссылок, без x/y (только предпросмотр) | — | — |
0x40 | количество цифр / флаг дополнения нулями (§11.10) | — | 1 Б |
0x48 | кадр — x,y,w,h,gap,align строка/столбец автопозиционирования | — | — |
0x5a / 0x5b | спецификация дуги для 0x80 / 0x81 (§11.15) | — | 19 Б / 17 Б |
0x5f | список метрик слота для 0x85 (§11.13) | — | — |
0x86 | узел отображаемого имени, всегда ровно 64 байта, завершается NUL, не рисуется | — | 64 Б |
| циферблат | слот | кол-во | activeIdx | id метрик | → активный |
|---|
| 275 SlopeTime | 0 | 6 | 0 | 1c 6a 48 24 19 76 | 0x1c калории |
| 275 SlopeTime | 1 | 6 | 4 | 1c 6a 48 24 19 76 | 0x19 шаги |
| 368 Function | 0 | 8 | 0 | 5f 1c 19 48 24 76 1a 8b | 0x5f температура |
| 368 Function | 1 | 8 | 6 | 5f 1c 19 48 24 76 1a 8b | 0x1a пульс |
| 273 Activity Mood | 0 | 4 | 0 | 1c 24 48 6a | 0x1c калории |
| 304 Elaborate 2 | 0/1 | 4 | 0 | 1c 48 6a 24 / 24 1c 6a 48 | 0x1c / 0x24 |