
Обратно спроектированный протокол 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, смещение/длина массовой передачи имеют обратный порядок байт).
Каждое неочевидное утверждение ниже помечено способом его установления:
Тестовое устройство для всех захватов: 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), координируемые управляющими кодами операций на канале команд.
✅ Целая реальная сессия прошла по одному каналу команд — во время 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` вместе образуют **opcode** (см. §6). 🔎 подтверждено с помощью фреймбилдера официального приложения (`C6117b.m30831g`).
- `chunkCount` = общее количество чанков для этой команды; `chunkIndex` **1-индексированный**.
- `chunkLen` = количество байт `chunk` в данном фрейме.
- Одна BLE-запись может быть фрагментирована по MTU канала; получатель буферизует сырые байты и повторно извлекает полные фреймы. Крупные полезные нагрузки разбиваются на несколько чанков (тот же `cmd1/cmd2`, увеличивающийся `chunkIndex`) и собираются по порядку.
### Соглашение об opcode (✅ подтверждено на проводе)
- `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` фрейма.
**Особенность открытого текста:** для незашифрованных opcode часы *учитывают* 4-байтный CRC в `chunkLen`, но **не** передают его. Поэтому при декодировании незашифрованного фрейма фактическая длина данных равна `chunkLen − 4`. (Зашифрованные фреймы несут CRC внутри шифротекста как обычно.)
Размер чанка (чтобы зашифрованные чанки выравнивались по границам блоков AES), при `maxWrite = mtu − 3`:
- зашифрованный: `floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- незашифрованный: `maxWrite − 11 − 4 − 2`
✅ Все наблюдаемые значения `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‑байтовый секрет устройства (получается с часов через команду оболочки
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
✅ Порядок переподключения (без трафика оболочки) был подтверждён на реальном захвате.
⚠️→✅
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, полезная нагрузка 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) всегда передаются открыто, поэтому последовательность команд видна в любом захвате даже без ключа — только зашифрованные полезные нагрузки требуют 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-слое. Их заголовки появляются в захватах, но семантика полезной нагрузки ⚠️ [неопределена].
Циферблат/прошивка/AGPS используют цикл init → chunk‑request/chunk‑write → finish‑ack:
(все cmd1 = FFFF.) Часы управляют циклом, отправляя DATA_CHUNK_REQUEST_*(offset, length) (offset/length = u32 big-endian); телефон отвечает DATA_CHUNK_WRITE_*, несущим payload[offset..offset+length] на характеристике данных. Подробности см. в §11–§12.
Полезная нагрузка TIME (FFFF 8004) = epochSeconds(i32, BE) ‖ utcOffsetMillis(i32, BE). Отправляется сразу после аутентификации, чтобы часы показывали местное время (и разблокировали запросы данных — см. §4.3).
⚠️ Метки времени здоровья с часов — UTC. Приложение-компаньон должно добавить локальное смещение UTC перед определением локального календарного дня/времени суток. (Группировка здоровья по сырому дню UTC приводит к смещению дня в неправильное локальное время.)
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 = bpm / 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 = REM, 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, aerobic/anaerobic, recoveryTime, VO₂max, cadence, 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 → chunk в §6.
Что на самом деле работает (✅ подтверждено вживую): создание фотоциферблата из любого изображения и его установка; установка любого из 103 магазинных циферблатов офлайн; изменение обложки структурированного циферблата (замена фона или любого не фонового спрайта) и перемещение его слоёв; переупорядочивание/переключение активного лица; и создание структурированного циферблата с нуля — конверт сцены
0x20декодирован и построитель реализован (§11.7), доказано офлайн, что он проходит все 103 магазинных циферблата байт-в-байт и выдаёт синтетические контейнеры, проходящие собственный валидатор прошивки. 🟡 единственный непроверенный шаг — наблюдение за синтетическим рендерингом с нуля на устройстве через9075(офлайн-доказательство структуры уже охватывает то, что раньше вызывало отказ0a). Нет барьеров в кодеках или транспорте и нет необходимости в инструментарии вендора. Старые утверждения «структурированный рендер запакован в RES-пакет/невозможен через 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)
Байт ответа: `01` = активировано и сохранено; `0a` = сохранено, но **не** активировано / отклонено. На Android каждый `DATA_CHUNK_WRITE` должен отправляться как **одно BLE-запись на фрейм** — объединение и повторная нарезка по MTU нарушает синхронизацию заголовков, и часы циклически запрашивают смещение 0.
- **`9063` (фото) = ДОБАВИТЬ.** Список циферблатов увеличивается (6→7); `watchfaceId = 0xFFFFFFFF` (пользовательский сторожевой маркер), поэтому он никогда не отклоняется как дубликат, и часы автоматически его активируют.
- **`9075` (структурированный) = ЗАМЕНИТЬ** слот `old_id`. `old_id` **должен** уже быть в списке (иначе `0a`). Чтобы переустановить уже присутствующий id, **сначала удалите его** (9055 list-minus-id), затем загрузите "новый" — повторное использование id на месте даёт `0a`.
### 11.3 Фото / пользовательский циферблат — ✅ полностью проверено от начала до конца
**Контейнер** (проверка байтов в прямом и обратном порядке; все поля 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 = standard LZ4 block over RGB565 little-endian, top-down (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 Структурированный / магазинный циферблат — контейнер и кодеки ✅
**Заголовок** (идентичен для всех 103 магазинных циферблатов; все поля little-endian):```
0x00 perDialId u32 LE [per-dial id/hash; NOT a content checksum — 4 "Default" dials share one]
0x04 version 0x00000001 [constant]
0x08 name char[] [NUL-terminated, e.g. "SlopeTime", "Metaball"]
0x18 size_a u32 LE [= filesize − 36] ✅ 100 % confirmed across 103 dials
0x1c size_b u32 LE [data-section length, < size_a]
0x20 3× u32 LE id/hash words [not a CRC]
0x2c name (repeated on larger dials)
~0x60 directory of layer records (61 xx 00 …) then the asset pool
There is no blocking checksum (CRC32/Adler32/byte-sum all fail to match) — repacking is not gated. 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); рисуемое **тело листа** (теги `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 font metrics] ‖ 40 01 00 ‖ flag ‖
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)`. Например, большая часовая цифра Digit Max 327 — это таблица из 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`, inline `61 1 base`
= диск) + под `0x5b` (`max` u16 `@+4`, =100 кроме 332=60). Реализовано в wfweb (`blendSector`).
- **Идентификатор источника данных** — блок атрибутов `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)`).
Обычные идентификаторы (§16): `0x07` час, `0x0b` минута, `0x0f` секунда, `0x16` месяц, `0x18` день недели,
`0x13` AM/PM, `0x19` ЧСС, `0x1b` заряд батареи %, `0x24` температура, `0x36` шаги, `0x70/71/72` углы стрелок,
`0x25–27` процент цели. (Это соответствует примеру группы `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`); проходит все 103 циферблата побайтово + синтетический проходит верификатор прошивки (§11.7). 🟡 Рендер на устройстве через `9075` ещё не заснят |
| Системные шрифты (`.font`) | ✅ **декодирование/рендер (все)** | LVGL bin (не проприетарный); 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.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 генерирует правильные вложенные окна).validate_rejects_bad_containers — отвергает плоское тело (→ , историческая ошибка ) и дочерний элемент, выходящий за своё окно (→ ).🟡 Всё ещё не проверено (нужны часы, не блокирующе): загрузка синтезированного с нуля через 9075 и наблюдение за его отрисовкой — офлайн-структурное доказательство уже охватывает то, что вызывало отклонение 0a.
Перекрёстно сверили рендер wfweb с официальными миниатюрами магазина (пиксельный оракул по всем 103 циферблатам) и закрыли четыре расхождения:
i16 (знаковые). ✅ Якоря могут быть отрицательными для элементов, выходящих за пределы холста — например, красная секундная стрелка 275-го циферблата находится в Y = 0xFFFC = −4 (спрайт 30×281, источник 0x12, повёрнут от центра за верхний край). Чтение X/Y как u16 (65532) заставляло защиту отбрасывать его. Оба парсите как знаковые и допускайте небольшой отрицательный диапазон.0x60 img_numbers верхнего уровня (не только внутри группы 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) — поэтому он выводил вариант "всегда включён" (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 была неверна — ширина относится только к расположению, а не к количеству.)
Таблица передачи находится в §6. Дополнительно подтверждённые моменты:
000000010000…. Полный цикл инициализации → [A05F ↔ 905F]×N → завершение наблюдался на линии (~892 фрагмента).9040–9042, завершение 9041) 🔎: структура сопоставлена; полезная нагрузка INIT2 = байты версии (например, 0b 00 00 39 = 11.0.0.57). Не проверено на практике (приложение отключает обновление ПЗУ здесь). Образы прошивки, по-видимому, не подписаны — целостность только по CRC32 (асимметричная подпись не наблюдалась при RE).FACTORY_RESET (009A 0001) используют одну аутентифицированную сессию, одной действительной BLE-аутентификации достаточно, чтобы стереть или (в принципе) заблокировать часы. Обращаться с осторожностью.✅ Аппаратное обеспечение доступно через BLE:
WORKOUT_GPS) и передача местоположения (GPS_PUSH).Нет барометра/альтиметра, компаса, гироскопа или датчика температуры кожи/тела. Внутренний NTC-термистор (температура платы/батареи) существует, но читается только через AT-канал (AT GETNTCTEMP, §14) — поток истории температуры кожи 0155 пуст на этой SKU.
Перехват виджетов данных (нет настоящего API для усложнений/привязки данных — см. §11.5): существующие текстовые поля часов можно использовать для отображения внешних данных с одного взгляда. Проверено ✅: строка города погоды (WEATHER_SET_1, например "BRA 2x1 ARG" появилась в виджете) и поля трека/исполнителя музыки; список контактов (20 × имя[32]+номер[25]) работает как прокручиваемая панель данных. Всё это отправляемые данные, а не постоянные усложнения.
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 — подтверждайте в каждом конкретном случае.
Некоторые функции присутствуют в прошивке, но отключены по SKU/региону и недоступны с телефона/BLE — для них требуется мод прошивки, что выходит за рамки:
ux2sys 0x9e, инициализируется из NVRAM/EFUSE/региона при загрузке; на этой SKU флаг поддержки 908b = 00. Не поддается влиянию с телефона, учётной записи или BLE (подтверждено экспериментом + RE). Приложение — лишь ретранслятор; аудио идёт телефон → облако Nothing.Не требуется для построения BLE-клиента — включено для полноты. Рендерер циферблатов прошивки привязывает каждый слот усложнения к числовому идентификатору геттера (таблица диспетчеризации на 142 записи). Избранные идентификаторы: 0x07 час, 0x0a комбинированный угол часов, 0x0b минута, 0x0f секунда, 0x18 день недели, 0x19 частота сердцебиения, 0x1b уровень заряда батареи, 0x24 температура, 0x36 шаги, 0x70/0x71/0x72 угол часовой/минутной/секундной стрелки, 0x25–0x27 процент цели. Кольцевые/дуговые усложнения индексируют предварительно отрендеренный лист кадров (например, 50 % = кадр 50 из 100), а не попиксельную дугу — кадры встроены в отправляемый .bin (§11.5), поэтому внешний набор RES не требуется.
QUICK_CARD (906D) ✅Домашние плитки часов. Телефон только выбирает, какие плитки показывать и в каком порядке — плитки рендерятся прошивкой (без канала содержимого). Первый байт полезной нагрузки = подкоманда: 00 = GET, 01 = SET; оба используют 0x906D (0x906C указан, но не используется — запрос к нему вызывает тайм-аут). Ответ = A06D.
🛑 Отправка вымышленного
assemblyIdстирает экраны часов (он принимает список, не может сопоставить идентификаторы, ничего не показывает). Отправляйте только идентификаторы, которые вы считали через GET; восстановление через официальное приложение или сброс к заводским настройкам.
Ответ GET ✅: status(1) ‖ 00 ‖ N(1) ‖ N × group, group = 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 = Спортивная карточка, sportId 2).
Слоты: каждый экран имеет 4 слота. Тип карточки определяет её размер — circular/square = 1 слот, rectangle = 2 слота. Проверка — чистая арифметика слотов (Σ ≤ 4 на экран); нет взаимно исключающих карточек. sportId равен 0, кроме спортивных карточек (87–91). Идентификаторы 64 и 95 не существуют.
Каталог assemblyId (каждый логический тип = непрерывный диапазон из 6 стилевых вариантов _0.._5; 0 = пустой слот):
Приведённые выше байтовые структуры были восстановлены из прошивки (1.0.0.73), официального APK (3.5.7) и расшифрованных захватов в реальном времени с реального устройства. Эталонная реализация этого проекта находится в core-rust/src/{commands,frame,crypto,health,session}.rs (Rust) и в Python-инструментах cmftool/ (pair.py, session.py, wf_codec.py, upload_custom.py, …).
| Назначение | Сервис | Характеристика | Свойства |
|---|
| Запись команд | 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 🔎 (live HR/cal/steps) | FFFF 9078 / FFFF A078 |
| FEMALE_CYCLE_SET / _RET | FFFF 9071 / FFFF A071 |
| SLEEP_CONFIG_SET / _RET (target min) | 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 (list/reorder/select) | 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 запрос/ответ | INIT2 запрос/ответ | CHUNK запрос/запись | FINISH подтверждение1/подтверждение2 |
|---|
| Циферблат (фото) | 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 | total_deep_s |
| 10 | 2 | total_core_s |
| 12 | 2 | total_rem_s |
| 14 | 2 | total_awake_s |
| 16 | 2 | ⚠️ [неопределено] (идентификатор сессии/оценка? наблюдаемые значения не совпадают с суммами записей) |
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; более ранние сообщения, что часы «игнорируют» его, были ошибкой расшифровки устаревшей сессии, а не проблемой полезной нагрузки.) 🔎 RE прошивки также показывает более длинный 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», отображаемое в интерфейсе, является фиксированным значением по умолчанию прошивки и не передаётся в полезной нагрузке.00DC 0001) ✅: count(1) = 36 слотов ‖ activityTypeCode[36] (активные коды, затем дополнение 00). Выбирает, какие виды спорта отображаются в меню тренировок часов.009B 0001) ✅: байт kind — 01 = 24/7 HR, 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) ✅: отправляет числовые идентификаторы городов, а не названия (01 ‖ count ‖ cityId(2 BE)…); часы сопоставляют идентификаторы из внутренней таблицы. Конфигурация 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-байтовых часов + city(32) + 7×8-байтовых восход/заход (LE). Температуры кодируются как (temp_c + 100) & 0xFF. ⚠️ Та же полезная нагрузка, отправленная на WEATHER_SET_2 (0066 0001), не обновляет виджет погоды на Pro 2 — всегда используйте 906B. (Строка города также является проверенным вектором перехвата данных — см. §13.)005D 0001) ✅: полезная нагрузка 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 |
0a0x61NotEnvelope0aChildOverflow:61 0a 00−18/−160a.bin. ⚠️ Настраиваемое усложнение создаётся как N узлов групп 0x68, расположенных в одних и тех же (x,y), каждый привязан к разному источнику (два круга 275-го: 0x1e/0x6a/0x48/0x24/0x19 для каждого слота — идентификаторы опций/стиля, не отображаемая метрика); два круга побайтово идентичны, за исключением их прямоугольника + байта экземпляра (0x79/0x7a). Какая метрика показывается (ШАГИ vs ККАЛ vs …) — это состояние ОЗУ/конфигурации устройства, поэтому статический предпросмотр не может воспроизвести её из файла — только по возможности.(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, когда прямой источник невозможен для числа (source-0 или получатель угла указателя 0x0a/0e/12/70/71/72) и позиция −18/−16 действительна и не равна нулю (защита от нуля пропускает цифры-дочерние элементы группы с relX=0).0x17 = дата (день месяца), 0x24 = температура — различаются. Циферблат 340 использует оба (0x17 "Jun 09" и отдельный 0x24 температура), поэтому 0x17 — это дата, а не температура. Циферблат, на котором часы показывают температуру в слоте 0x17, — это пользовательское усложнение (состояние устройства), а не значение по умолчанию из файла.| dec | карта | dec | карта |
|---|
| 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 / Цикл |