
Protocollo BLE reverse-engineered per il CMF Watch Pro 2, con documentazione del layout GATT, frame di comando crittografati AES-128-CBC, handshake di autenticazione e sincronizzazione dei dati sulla salute per lo sviluppo di app companion alternative.
Ufficioso. Questo documento descrive il protocollo Bluetooth Low Energy (BLE) del CMF Watch Pro 2 (CMF by Nothing), ricostruito tramite reverse engineering per un'app companion alternativa. Non è affiliato o approvato da Nothing/CMF. Utilizzalo a tuo rischio.
Tutti gli interi multi-byte nell'header del frame e negli opcode sono big-endian. Gli interi all'interno
dei payload dei comandi sono little-endian salvo diversa indicazione (questo rispecchia il firmware del dispositivo) —
attenzione alle eccezioni (GOALS_SET, GPS_PUSH, offset/lunghezza del trasferimento bulk sono big-endian).
Ogni affermazione non ovvia di seguito è etichettata con il metodo con cui è stata stabilita:
Dispositivo di test per tutte le catture: CMF Watch Pro 2-5485, fw 1.0.0.73, seriale CI04102520008192,
MCU Actions ATS3089C (Cortex-M4), schermo 466×360.
Il telefono è il client GATT; l'orologio è il dispositivo periferico, che si pubblicizza come CMF Watch Pro 2-XXXX
(4 caratteri esadecimali).
Abilita le notifiche scrivendo 01 00 su ciascun CCCD (00002902-…). Il canale comandi
(fff1/fff2) trasporta il protocollo incorniciato descritto di seguito. Il canale shell (77d4…) trasporta
testo in stile AT semplice (es. AT GETSECRET; vedi §14). Il canale dati (02f0…) trasporta
grossi blob binari (watchface, firmware, AGPS), coordinati da opcode di controllo sul canale comandi.
✅ Un'intera sessione reale è stata eseguita sul singolo canale comandi — durante una cattura di 160 s ad uso intensivo non c'è stato nessun traffico sui canali dati/firmware o shell tranne durante un trasferimento esplicito OTA/watchface.
0xF5)Ogni messaggio sul canale comandi è racchiuso in uno o più frame con header di 11 byte:``` +------+-----------+--------+-------------+-------------+--------+-------------------+ | 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` insieme formano **l'opcode** (vedi §6). 🔎 confermato rispetto al costruttore di frame dell'app ufficiale (`C6117b.m30831g`).
- `chunkCount` = numero totale di chunk per questo comando; `chunkIndex` è **a base 1**.
- `chunkLen` = numero di byte di `chunk` in questo frame.
- Una singola scrittura BLE può essere frammentata dal MTU del link; il ricevitore bufferizza i byte grezzi e riestrae i frame completi. I payload grandi vengono suddivisi in più chunk (stesso `cmd1/cmd2`, `chunkIndex` crescente) e riassemblati in ordine.
### Convenzione degli opcode (✅ confermata sul filo)
- `cmd1 = 0xFFFF`: `cmd2` in `0x80xx`/`0x90xx` = telefono→orologio (richiesta/impostazione); `0x00xx`/`0xa0xx` =
orologio→telefono (risposta). Le coppie corrispondono per il byte basso (`0x9055`↔`0xa055`, `0x8051`↔`0x0051`).
- `cmd1` specifico della funzionalità: suffisso `cmd2` = `0x0001` **SET**, `0x0002` **GET**, `0x0003` **ACK**.
### Corpo del chunk
Per ogni chunk, il corpo è `payloadPiece ‖ CRC32_LE(payloadPiece)` (CRC a 4 byte, little-endian,
zlib/IEEE). Se il comando è **cifrato** (vedi §3), l'intero `payloadPiece ‖ CRC` viene poi
cifrato con AES-128-CBC/PKCS7 e quel ciphertext diventa il `chunk` del frame.
**Particolarità del testo in chiaro:** per gli opcode in chiaro l'orologio *conta* il CRC a 4 byte in `chunkLen` ma **non** lo trasmette. Quindi decodificando un frame in chiaro, la lunghezza effettiva dei dati è `chunkLen − 4`.
(I frame cifrati portano il CRC all'interno del ciphertext come di consueto.)
Dimensionamento del chunk (per far sì che i chunk cifrati cadano sui confini dei blocchi AES), con `maxWrite = mtu − 3`:
- cifrato: `floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- in chiaro: `maxWrite − 11 − 4 − 1`
✅ Tutti i valori `chunkLen` osservati nei frame cifrati erano multipli di 16 (l'allineamento ai blocchi è rispettato).
---
## 3. Primitivi crittografici
- **AES-128-CBC** con padding **PKCS7** e un **IV fisso** (dal firmware
`CmfCharacteristic.AES_IV`):
`50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 5A`.
- **CRC32** (zlib/IEEE), emesso come 4 byte little-endian.
- **SHA-256** sulla concatenazione delle parti.
Derivazione della chiave:```
authkey = SHA256( rnd1 ‖ rnd2 ‖ secret )[0..16] // persisted across sessions
sessionKey = SHA256( nonce ‖ authkey )[0..16] // per connection
secret = segreto del dispositivo a 16 byte (ottenibile dall'orologio tramite il comando shell
AT GETSECRET → GETSECRET:<32-hex>,OK).rnd1 = 16 byte casuali scelti dal telefono; rnd2 = 16 byte casuali dall'orologio.nonce = byte dalla risposta nonce dell'orologio.Dopo che la chiave è stata impostata, tutti i frame del canale di comando sono cifrati AES tranne i codici operativi in chiaro elencati in §5.
✅ Entrambe le derivazioni convalidate: authkey recuperato dal file ntwatch.db di un telefono rooted corrisponde al
valore derivato da un rnd1/rnd2/secret catturato; sessionKey riprodotto da un nonce catturato
decifra i frame live.
Due percorsi di ingresso condividono la stessa coda 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
In caso di `AUTH_FAILED (0xFFFF,0xA061)` o mancata corrispondenza della firma, l'autenticazione fallisce.
### 4.2 Riconnessione (authkey già conosciuto)```
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
✅ L'ordine di riconnessione (nessun traffico shell) è stato osservato integro in una cattura reale.
⚠️→✅
TIMEè obbligatorio prima delle interrogazioni dati. DopoInitialized, l'orologio non risponderà aBATTERY,SERIAL_NUMBER_GETo all'handshakeACTIVITY_FETCH_*fino a quando non viene inviato unTIME (FFFF 8004)nella sessione — senza di esso, arriva solo unFIRMWARE_VERSION_RETnon richiesto e tutto il resto va in timeout. ✅ confermato dal vivo (Pixel 8a): inviando i tre GET senzaTIME→ risponde solo il firmware; inviandoTIMEper primo → iniziano a rispondere batteria e seriale.
Ordine raccomandato per la fase 2: TIME → FIRMWARE_VERSION_GET → SERIAL_NUMBER_GET →
BATTERY (0xA5) → push di configurazione → sincronizzazione salute (§8).
Non esiste un opcode di "lettura" separato per la maggior parte delle impostazioni. Inviare un *_GET (cmd2 = 0x0002, payload
0xA5) fa sì che l'orologio risponda con l'opcode SET (cmd2 = 0x0001) che trasporta il valore corrente.
I comandi SET vengono riconosciuti con cmd2 = 0x0003 e un corpo vuoto.
I frame sono crittografati con AES una volta impostata una chiave, eccetto questi opcode, che sono sempre in chiaro:
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)Le intestazioni dei frame (cmd1/cmd2) viaggiano sempre in chiaro, quindi la sequenza dei comandi è visibile in
qualsiasi cattura anche senza la chiave — solo i payload crittografati necessitano di sessionKey.
(cmd1, cmd2)GET/SET/REQUEST = telefono→orologio; RET/REPLY/ACK/RESPONSE/DATA = orologio→telefono.
| Nome | cmd1,cmd2 |
|---|---|
| MUSIC_INFO_SET / _ACK | FFFF 905C / FFFF A05C |
| MUSIC_BUTTON | FFFF A05D |
| Nome | cmd1,cmd2 |
|---|---|
| WEATHER_SET_1 (quello che funziona) | FFFF 906B |
| WEATHER_SET_2 (ignorato su Pro 2 — vedi §9) | 0066 0001 |
Opcode solo JS (
FFFF 8051,FFFF 0051,FFFF 90A2,FFFF 90C5,FFFF A056,FFFF 908A/908Bstato/supporto ChatGPT) sono gestiti nel bytecode Hermes dell'app, non nel layer Java. Le loro intestazioni appaiono nelle catture ma la semantica del payload è ⚠️ [incerta].
Watchface / firmware / AGPS usano un ciclo init → richiesta-scrittura-chunk → fine-ack:
(tutti con cmd1 = FFFF.) L'orologio guida il ciclo emettendo DATA_CHUNK_REQUEST_*(offset, lunghezza)
(offset/lunghezza = u32 big-endian); il telefono risponde con DATA_CHUNK_WRITE_* che trasporta
payload[offset..offset+lunghezza] sulla caratteristica dati. Vedi §11–§12 per i dettagli.
Il payload di TIME (FFFF 8004) = epochSeconds(i32, BE) ‖ utcOffsetMillis(i32, BE). Inviato subito dopo
l'autenticazione in modo che l'orologio mostri l'ora locale (e sblocchi le interrogazioni dati — vedi §4.3).
⚠️ I timestamp della salute dall'orologio sono UTC. L'app companion deve aggiungere l'offset UTC locale prima di derivare il giorno di calendario / ora del giorno locale. (Raggruppare la salute per giorno UTC grezzo fa slittare il giorno all'ora locale sbagliata.)
Il payload di TIME_FORMAT (005F 0001) = 1 byte: 00 = 24h, 01 = 12h.
ACTIVITY_FETCH_1; l'orologio risponde ACTIVITY_FETCH_ACK_1 (primo byte 01 ⇒ pronto).ACTIVITY_FETCH_2; l'orologio spinge quindi un burst di frame dati:
ACTIVITY_DATA, HEART_RATE_*, SPO2, STRESS, SLEEP_DATA, WORKOUT_SUMMARY[_V3].La sincronizzazione è sequenziale (deve seguire TIME; l'orologio rilascia i flussi dopo ACK_2), non un
singolo burst. Una sessione pesante spinge circa 170–210 frame di notifica in circa 160 s. ✅
ACTIVITY_DATA (32 byte ciascuno, LE) ✅Unità calorie: le calorie di attività sono riportate in cal (calorie-grammo). Dividere la somma giornaliera per 1000 per ottenere kcal. (Le calorie del riepilogo allenamento, al contrario, sono già in kcal.)
timestamp(i32 LE) ‖ valore(i32 LE)
(valore = bpm / % SpO₂ / indice stress).00DA 0001) è diverso — 5 byte: timestamp(i32 LE) ‖ hr(u8).
✅ esempio dal vivo 5e dc 29 6a 4e → ts, hr = 78 bpm. Intervalli punteggio stress: 1–29 / 30–59 / 60–79 / 80–99.SLEEP_DATA (intestazione 18 byte + N × 8 byte record) ✅Un SLEEP_DATA = una sessione di sonno; una notte può contenerne diverse (micro-risvegli dividono le sessioni).
Intestazione:
Ogni record di 8 byte: timestamp(u32) ‖ durata_s(u16) ‖ stadio(u16).
Codici stadio: 1 = Profondo, 2 = Leggero/leggero, 3 = REM, 4 = Veglia. ✅ validato contro una notte intera
(due sessioni, i totali D/L/R/V coincidono).
WORKOUT_SUMMARY v1 (54 byte) / _V3 (0160 0001)v1: inizio(u32), fine(u32), durata_s(u32), poi tipo/calorie/passi/distanza/HR-medio e un
blocco GPS/esteso. ✅ layout v1 confermato contro il firmware. WORKOUT_SUMMARY_V3 è un layout più recente
per gli stessi dati più un blocco esteso di ~40 byte (exerciseLoad, aerobico/anaerobico, recoveryTime,
VO₂max, cadenza, PAI, migliori tempi di corsa…). Il set di campi è noto (dal database Room dell'app) ma gli
offset esatti all'interno di quel blocco di 40 byte sono ⚠️ [incerti] — per chiuderli serve una cattura grezza
di un allenamento GPS.
Le stringhe sono UTF-8, troncate al byte alla dimensione del campo (il troncamento può dividere un carattere
multi-byte, corrispondente al comportamento di s.encode()[:max] del firmware); i campi corti sono zero-padded a destra.
0065 0001) ✅: iconCode(1) ‖ 0x00 ‖ when(u32 BE) ‖ titleLen(1) ‖ title ‖ body.
iconCode seleziona l'icona dell'app (WhatsApp=8, Telegram=12, Instagram=18, Gmail=27; sconosciuto=0xFF).
Titolo ≤ 20 byte, corpo ≤ 128 byte. Inviato da un client → l'orologio lo ha visualizzato + ACK 0065 0003.005C 0001) ✅: risposta = level(1) ‖ charging(1) (es. 3b 00 = 59 %, non in carica).00DE 0001) ✅: len(1) ‖ ASCII (es. 10 + "CI04102520008192").0095 0001) ✅: altezza_cm(1) ‖ peso_kg(1) ‖ età(1) ‖ sesso(1: 1=M)
(es. = 172 cm / 73 kg / 31 / maschio).now/utc_offset come parametri espliciti
(deterministici, testabili). Il trasporto fornisce l'ora reale.TIME governa tutto (§4.3) — inviarlo per primo o l'orologio rimane muto sulle interrogazioni dati.GOALS_SET e
GPS_PUSH sono big-endian, e offset/lunghezza del trasferimento bulk sono big-endian.L'orologio supporta (a) quadranti foto/personalizzati (un'immagine di sfondo + un orologio digitale disegnato dal firmware) e (b) quadranti strutturati (quadranti integrati / del negozio: uno sfondo più layer sprite posizionati, lancette e widget di testo). Entrambi vengono trasferiti sul canale dati tramite il ciclo init → chunk in §6.
Cosa funziona effettivamente (✅ validato dal vivo): costruire un quadrante foto da qualsiasi immagine e installarlo; installare offline uno qualsiasi dei 103 quadranti del negozio; reskinning di un quadrante strutturato (scambiare lo sfondo o qualsiasi sprite non di sfondo) e spostare i suoi layer; riordinare/cambiare il quadrante attivo; e costruire un quadrante strutturato da zero — l'involucro scena
0x20è decodificato e il costruttore è implementato (§11.7), provato offline a fare round-trip di tutti i 103 quadranti del negozio byte per byte e ad emettere contenitori sintetici che superano il validatore del firmware stesso. 🟡 l'unico passo non provato è vedere un render sintetico da zero sul dispositivo tramite9075(la prova offline strutturale copre già ciò che causava il rifiuto0a). Non c'è nessuna barriera di codec o trasporto e nessun bisogno della toolchain del fornitore. Le vecchie affermazioni "il render strutturato è cotto nel RES-pack / impossibile via BLE" e "cf=0x1f codec lato server" erano sbagliate (un bug di offset+byte-per-pixel) — il firmware renderizza i quadranti strutturati guidati dai dati dal file che invii.
DIAL_COMMAND (9055 / a055) ✅a055 = risultato(u8) ‖ indiceSelezionato(u8) ‖ totale(u8) ‖ max(u8) ‖ N × dialId(u32 LE) ‖ ffffffff. Esempio: 01 05 06 07 … = attivo #5, 6 quadranti, max 7.CHANGE_DIAL (009F 0001) è inerte su fw 1.0.0.73 (restituisce una costante, non cambia quadrante) — non
usarlo.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)
Byte di risposta finale: `01` = attivato e salvato; `0a` = memorizzato ma **non** attivato / rifiutato. Su
Android ogni `DATA_CHUNK_WRITE` deve essere inviato come **una scrittura BLE per frame** — concatenare e
ri-sezionare in base all'MTU desincronizza gli header e il loop dell'orologio che richiede offset 0.
- **`9063` (photo) = APPEND.** La lista dei quadranti cresce (6→7); `watchfaceId = 0xFFFFFFFF` (sentinel
personalizzato) in modo che non venga mai rifiutato come duplicato e l'orologio lo attivi automaticamente.
- **`9075` (structured) = REPLACE** lo slot `old_id`. `old_id` **deve** già essere nella lista
(altrimenti `0a`). Per reinstallare un id già presente, **cancellalo prima** (9055 list-minus-id)
poi caricalo "nuovo" — riutilizzare un id in posizione restituisce `0a`.
### 11.3 Foto / quadrante personalizzato — ✅ completamente convalidato end-to-end
**Contenitore** (round-trip verificato byte per byte; tutti i campi 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 conta dal primo byte LZ4). L'app ufficiale usa LZ4-HC e rimuove l'intestazione/piè di pagina di 21 byte del blocco LZ4; funziona anche un encoder LZ4 solo letterali (literals-only) — l'orologio accetta qualsiasi LZ4 valido, l'identità dei byte (byte-identity) non è richiesta. I pixel al di fuori del cerchio inscritto (centro 233,233, raggio 233) sono impostati a 0x0000.
INIT_2 per 9063 — header esatto (✅ questo è quello che funziona):```
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` = lunghezza esatta di `.bin`; `FFFFFFFF` = `watchfaceId` personalizzato; `styleId` da 0 a 4 seleziona il layout dell'orologio digitale integrato (viene sempre disegnato — non esiste "off"); `posX/posY` lo posizionano (valori noti 56 / 77); `color565` lo colora (es. `FFFF` = bianco). ⚠️ La forma più corta `A5 ‖ size ‖ watchfaceId` viene **rifiutata** con fine `0a` — usa l'intestazione completa sopra. (Implementazione di riferimento: `core-rust/engine.rs::build_wf_init2`, che rispecchia `C6135t.m31104u` nell'app ufficiale.)
**Ricetta:** ridimensiona l'immagine a 466×466 (e una miniatura 270×270), converti in RGB565-LE dall'alto verso il basso, azzera opzionalmente i pixel fuori dal cerchio, comprimi ciascuno con LZ4, assembla il contenitore sopra e carica tramite la pipeline `9063` con `watchfaceId = 0xFFFFFFFF`. (Codec di riferimento: `core-rust/watchface.rs`, `work/codec_dfa.py`.)
### 11.4 Quadrante strutturato / store dial — contenitore e codec ✅
**Intestazione** (identica per tutti i 103 store dial; tutti i campi in 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
Non c'è checksum bloccante (CRC32/Adler32/byte-sum falliscono tutti) — il reimpacchettamento non è bloccato. I dial stub (~173 B, es. id 273/274/277) sono segnaposto per i volti cotti nella ROM: header + directory, nessun asset reale.
Asset — ciascuno è dimsWord(u32 LE) ‖ len(u32 LE) ‖ LZ4(payload), dove
cf = dimsWord & 0x1f, w = (dimsWord >> 10) & 0x7FF, h = (dimsWord >> 21) & 0x7FF, e len
conta a partire dal primo byte LZ4 (il 1f 00 01 00 che spesso si vede lì è il primo token LZ4 — non
saltarlo). Dimensione decompressa = w·h·bpp:
✅ Tutti i 4151/4151 asset nei 103 dial si decodificano esattamente con un decompressore lz4.block
standard a w·h·bpp. La trasparenza è il byte alpha (cf=5/24) o 0x0000 (cf=4 fuori dal
cerchio) — non c'è RLE né "escape". Codifica = ri-raster → LZ4 standard → [dimsWord][len][LZ4].
INIT_2 per 9075 — corpo crittografato con AES:```
kind(1) ‖ old_id(u32 LE) ‖ new_id(u32 LE) ‖ file_len(u32 LE)
`kind` = `0x02`/`0x03`; `old_id` = quadrante attivo corrente (da `9055`); `file_len` = dimensione reale del `.bin` (= `@0x18 + 36`). Installare un `.bin` del negozio così com'è è la via garantita (Ring Data id 359 + altri 102 confermati). (Riferimento: `core-rust/engine.rs::build_dial_replace_init`.)
### 11.5 Grammatica di directory strutturata ✅ (decodificata e implementata — RIVISTA 2026-07-02)
> **⚠️ Revisione (2026-07-02): lo schema piatto `61 01 00` record di seguito era sistematicamente
> SFASATO DI UNO.** Il corpo scena è un TLV pulito (§11.7); un **corpo foglia** disegnabile (tag `0x30`/`0x38`
> statici, `0x70` puntatore) è:
>
> ```
> 01 xx 00 [X u16][Y u16] …attrs… 61 [count u16][base u32][count×id u16] [05 05 00 01 pivX pivY]
> ```
>
> - attr `0x01` apre il corpo: **X,Y = angolo superiore sinistro** sulla tela 466² (lo `s16 x,y` dell'Sdk
> `sty_picture_t`).
> - la **tabella cornici `61 …` chiude il corpo** (`base` = puntatore asset; `count` 1 = immagine, 10/11 =
> atlante cifre — il vecchio "tipo record `0a/0b`" era in realtà questo conteggio! — 7/13/2 = foglio
> cornici complicazione).
> - extra puntatore: sorgente+scala `[src] 00 3c 00` dentro l'attributo `0x01`; perno nel
> **trailer** `05 05 00 01 [pivX][pivY]`. **Centro rotazione = `(X+pivX, Y+pivY)` per puntatore** —
> non un punto fisso (233,233): esistono subquadranti fuori centro (es. le lancette del quadrante 366
> ruotano attorno a 150,150).
>
> La scansione lineare per `61 01 00` stava cucendo la tabella cornici+perno dell'elemento **N** allo X/Y
> (e byte tag, il vecchio "f3") dell'elemento **N+1** — sembrava *corretta* solo su quadranti analogici le cui
> lancette adiacenti condividono geometrie quasi identiche. Il "muro della variante compatta" (specifica 24 §24.4.5)
> era lo stesso fraintendimento. Implementato come `scan_scene_drawables` in `core-rust/watchface_struct.rs`
> e `wfweb/src/codec/parse.ts` (scena = sorgente primaria per immagini/puntatori; scansione piatta mantenuta per
> fallback testo + non-involucro). Validato dall'oracolo `wfweb/compare.html` (render vs PNG ufficiali del
> negozio, 99 quadranti): 64→72 buoni, 8→5 cattivi, diff media 9.3→7.4%.
Lettura storica a record piatti (sostituita, mantenuta per contesto):
- **Immagine statica** (`61 01 00`): `asset_ptr(u32) ‖ elemId(u16) ‖ 05 05 00 01 ‖ pivotX(u16) ‖
pivotY(u16) ‖ 3B ‖ 01 ‖ 1b 00 ‖ X(u16) ‖ Y(u16)`. Angolo superiore sinistro sulla tela 466² = `(X−pivotX, Y−pivotY)`.
- **Puntatore/lancetta** — stesso record immagine, ruotato in esecuzione. **Centro rotazione = `(X+pivotX, Y+pivotY)`**
(≈ 233,233 sui quadranti analogici). La **sorgente dati è un `u8` all'offset record `+36`**, scala `u16` a
`+38` (=60): `0x0a`/`0x70` = ora (`h·30°+m·0.5°`), `0x0e`/`0x71` = minuti (`m·6°+s·0.1°`),
`0x12`/`0x72` = secondi (`s·6°`). ✅ confermato dal disassemblaggio dei getter (RTC fallback 10:10:30).
- **Widget testo/numero** (`61 0a 00`): `asset_ptr(u32) ‖ [10×u16 metriche font] ‖ 40 01 00 ‖ flag ‖
3B ‖ 01 ‖ u16 ‖ X(u16) ‖ Y(u16)`. `asset_ptr` punta al glifo "0"; cifra *d* = l'asset a
`index("0") + d` (10 sprite consecutivi cf=5, es. `0123456789` e punteggiatura `,°`). ✅ renderizzato.
- **Riempimento complicazione = indice cornice** (✅ confermato per complicazioni cifra/enumerazione/indicatore, count>1): il
valore indicizza un **foglio cornici pre-renderizzato** nel `.bin` — `frame = (count−1)·val/100` (percento) o
`frame = valore` (cifra flip / enumerazione). Tabella cornici = sottorecord `61 ‖ count(u16) ‖ base(u32) ‖
count×id(u16)`. Es. l'ora grande del Digit Max 327 è un foglio di 13 cornici (numeri 0–12), `frame = ora`.
- **Anello di progresso / arco = clip di settore runtime** (✅ 2026-07-02, **corregge la lettura "gli anelli sono fogli cornici"
nella specifica 25 §2**): l'elemento tag **`0x81`** porta un **singolo** disco pieno (`61` tabella cornici
`count == 1`), e il settore parziale è quel disco **clippato a un settore circolare** (`frac = valore/max`,
in senso orario dalle 12) — verificato pixel per pixel su 322 Glare 2 e confermato `count==1` attraverso
**20 quadranti**. Su disco: corpo `0x81` = sub `0x01` (geometria `x@+0 y@+2 w@+4 h@+6`, inline `61 1 base`
= disco) + sub `0x5b` (`max` u16 `@+4`, =100 tranne 332=60). Implementato in wfweb (`blendSector`).
- **Id sorgente dati** — il blocco attr `82` dell'elemento si trova a `delim+3` (dopo l'ultimo `40 01 00`),
e l'**id sorgente è un `u8` a `+0x14`** (anche `relX@+0x07 s16`, `relY@+0x09 s16`, `ancoraggio@+0x0C/0E`,
`mode@+0x15`, `frame-count@+0x1A`). Ancoraggio < 0 = allineamento al bordo del genitore. 🔎 Il firmware risolve
l'id tramite una tabella getter di 142 voci a `0x101f371c` (ciascuna chiama `ux2sys_get(type)`). Id comuni
(§16): `0x07` ora, `0x0b` minuti, `0x0f` secondi, `0x16` mese, `0x18` giorno settimana, `0x13` AM/PM,
`0x19` FC, `0x1b` batteria %, `0x24` temperatura, `0x36` passi, `0x70/71/72` angoli lancette,
`0x25–27` % obiettivo. (Corrisponde al gruppo `0x07:0x0b:0x0f` = HH:MM:SS esempio sotto.)
- **Nodo gruppo** (`0x68`): annida i suoi figli all'interno del proprio corpo TLV (`0x60` = valore/testo,
`0x30` = statico); ogni `0x60` porta il suo id sorgente a `data+16`. Es. un gruppo `0x07:0x0b:0x0f` =
orologio HH:MM:SS. Parser elementi TLV = `0x100db55c` (tabella salti indicizzata da `tag−0x70`).
### 11.6 Matrice di authoring
| Percorso | Stato | Note |
|---|---|---|
| Quadrante fotografico da qualsiasi immagine | ✅ **fatto** | §11.3; validato su dispositivo |
| Installare qualsiasi dei 103 quadranti del negozio | ✅ **fatto** | §11.4; `9075`, `old_id`=attivo |
| Reskin sfondo cf=4 di un quadrante del negozio | ✅ **funziona live** | scambia il payload FULL in posizione, imposta la `len` dell'asset alla **nuova** dimensione del blocco (≤ vecchio), mantieni la stessa impronta file, installa da zero |
| Ri-authoring tramite template (scambia i pixel di qualsiasi layer + sposta geometria) | ✅ **render tramite BLE** | quadrante 373: bg→ciano + uno sprite cf=5→rosso + spostato X 224→100, tutto renderizzato, lancette live |
| Quadrante strutturato sintetico 100 % da zero | ✅ **builder fatto, validato offline** | builder involucro `0x20` in `watchface_struct.rs` (`build_container`/`serialize`/`validate_container`); round-trip tutti e 103 i quadranti byte esatti + sintetico supera validatore firmware (§11.7). 🟡 render su dispositivo tramite `9075` non ancora filmato |
| Font sistema (`.font`) | ✅ **decodifica/render (tutti)** | LVGL bin (non proprietario); 32 font numerici (`num*/nm*`, non compressi) + 24 font testo (`font*`, LVGL RLE `comp=1`) tutti decodificati — 12208 glifi, 0 overrun, ASCII completo. RLE = LVGL v8.3 `lv_font_fmt_txt.c` (SINGLE/REPEATE/COUNTER a 3 stati + pre-filtro XOR per riga), portato 1:1, nessun disasm |
⚠️ Fallimenti di reskin/ri-authoring che causano schermo nero o `0a`: lasciare la **vecchia `len` dell'asset** (il
watch legge oltre il blocco → overrun → nero); **aumentare il file** (rifiutato all'installazione); riutilizzare un id
**in posizione** invece di un'installazione da zero.
### 11.7 L'involucro scena `0x20` — decodificato e builder implementato ✅
Un quadrante **ri-authoring** reale viene renderizzato perché preserva l'involucro scena del file. Un corpo puramente
sintetico di record `61 …` piatti viene **rifiutato** — il parser firmware (`WFManager_Parser`, `0xdb35c`)
richiede che il corpo (dall'offset `0x24`) inizi con un contenitore scena `0x20`. Il file completo è:```
[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
The scene is a clean nested TLV — [tag u8][len u16 LE][body], container tags 0x20/0x21/0x22/0x68
recursing, leaf drawables 0x30 (static) / 0x70 (element/pointer) / 0x80 / 0x81 / 0x86 (name).
(The flat 61 01 00 / 61 0a 00 records are patterns that live inside the drawable bodies; the
old parser found them heuristically — and stitched adjacent bodies together, see the §11.5 revision.
The drawable body layout is now fully decoded there.) Every child's offset+len must fit inside its
parent's window;
first body byte ≠ 0x20 → parser error −16; a child overrunning its window → −2; either makes the
9065 handler (0xeb50c) write finish .
Builder is implemented and offline-validated (core-rust/watchface_struct.rs:
SceneNode / serialize / parse_scene / validate_container / build_container /
build_container_raw; CLI cmfwatch-wfgen reframe):
scene_roundtrip_identity — all 103 store dials: parse_scene→serialize reproduces the scene
byte-for-byte (recomputed nested lens match) and validate_container passes on every one.build_reframe_identity / CLI reframe — reassembling the whole .bin from scratch reproduces
the file byte-for-byte except 1 name-padding byte (@0x17; not a checksum).build_container_synthetic — composes a new dial (background + drawable nested in 20→21) that
passes the firmware's exact invariant (build_container emits correct nested windows).validate_rejects_bad_containers — rejects a flat body (→ , the historic
bug) and a child that overruns its window (→ ).🟡 Still unproven (needs the watch, non-blocking): uploading a from-scratch synthetic over 9075
and watching it render — the offline structural proof already covers what caused the 0a reject.
Cross-referenced the wfweb render against the official store thumbnails (pixel oracle over all 103 dials) and closed four gaps:
i16 (signed). ✅ Anchors can be negative for elements that
extend off-canvas — e.g. 275's red second hand sits at Y = 0xFFFC = −4 (a 30×281 sprite,
source 0x12, rotated from center off the top edge). Reading X/Y as u16 (65532) made the
guard drop it. Parse both as signed and allow a small negative range.0x60 img_numbers (not only inside a 0x68 group), and
the real data source is the u8 at record offset −5 — the forward 82-attr scan is
systematically off-by-one here and grabs the next sibling's attr (in 275 the minute digit
picked up the weekday 0x18). 275's "10:10" = hour 0x07@X≈306 + min 0x0b@X≈369 with the
as an adjacent static between them, each an 11-glyph atlas (). ⚠️ When correcting X/Y
from , the , or a re-export corrupts those bytes (breaks
same-footprint → ).Also: the official store thumbnails are rendered at 10:10 (classic marketing time), not 10:12 — matching the oracle time to 10:10 drops mean pixel-diff noticeably. wfweb's parser now round-trips all 103 dials byte-exact (the X/Y write-offset fix above cleared the last mismatches).
0x22 AOD container into its own view. ✅ The scene walker already skips 0x22,
but the flat text/number scan walked the whole [0x30, firstAsset) — so it emitted the
always-on (AOD) variant of each element as a normal layer. On "Gradient" the AOD gray date
atlas (offset in 0x22) drew on top of the red normal one. Fix: tag every 0x22 record with
layer.aod=true (with its own dedup set) and let renderAt(…, aod) show them only in AOD mode
(normal mode hides aod layers; AOD mode hides normal ones; the background is swapped by setAod
and always draws). Net oracle win in normal mode across the corpus (284: 31%→21%, +18 others) —
the AOD variants were overdrawing many dials — and the editor's AOD toggle now shows the real
always-on layout instead of the normal ones. The real AOD is a black screen (no dimmed scene):
if the dial has no dedicated AOD background frame (dial.aod), the normal scene is hidden in AOD
mode so it renders black + the 0x22 elements at their own colour. AOD parse via the
scene walker too (it now recurses the container tagging drawables , instead of leaving
them to the flat scan where their pivot didn't match → "unpositioned"); AOD hands rotate at the
canvas centre (the sometimes carries an off-centre hand x/y the firmware ignores — e.g.
Gradient's hour ). The editor also exposes this as an
(§UI): each screen shows only its own layers and edits persist independently. Normal-mode render is
byte-identical throughout; roundtrip stays byte-exact on all 103 dials.40 01 00 XX byte (✅ firmware-confirmed)How many digits an img_number draws is a single byte in the field record — the data byte XX
of the element's 40 01 00 XX attribute sub-record (the 0x40 sub-record that sits after the
61 [count][base][glyph-ids] frame table):
XX & 0x0F = number of digit slots (0 ⇒ firmware default 7).0x80 = zero-pad (show leading zeros, e.g. "09" vs "9").Confirmed by disassembling the firmware (XIP image 0x10000000; render routine 0x100d8e60):
NDIG = ldrb[40sub+3] & 0x0F (→7 if 0); the value is clamped value % 10^NDIG and exactly NDIG
glyphs are drawn MS-first, leading zeros suppressed unless bit7. The u16 after the source (60 for
date, 1000 for kcal) is NOT the count — it only feeds the thousands/millions separator-glyph
insertion (cmp #1000/#1000000), which is why editing it did nothing. Source id doesn't cap either.
Corpus histogram over all 620 number fields matches: 2-digit fields (hour/min/sec/date/temp/HR) end
40 01 00 02/0x82; kcal …04; steps …05; single-digit clock splits 0x81. So the date field
40 01 00 82 = 2 digits, zero-padded — that's the entire reason a rebound Fahrenheit temperature
(≥100) was truncated.
Fix / editor: wfweb parses digitCount/digitZeroPad (+digitCountOff) for number fields,
exposes "Digits" + "Zero-pad" in the inspector, writes the byte in-place (same-footprint),
and the preview clamps/pads to digitCount to mirror the firmware. So rebinding a field's source and
setting its digit count works for any field (e.g. date→temperature °F → Digits 3). Normal-mode
oracle unchanged (0 regressions, 3 tiny improvements); roundtrip byte-exact on all 103 dials.
(The earlier "Digits width"/rectW hypothesis was wrong — width is layout only, not the count.)
The transfer table is in §6. Additional confirmed points:
000000010000…. The full
init → [A05F ↔ 905F]×N → finish loop was observed on the wire (~892 chunks).9040–9042, finish 9041) 🔎: structure mapped; INIT2 payload = version bytes
(e.g. 0b 00 00 39 = 11.0.0.57). Not field-tested (the app disables FW update here). Firmware
images appear to be unsigned — integrity is CRC32 only (no asymmetric signature observed in RE).FACTORY_RESET (009A 0001) share the authenticated session, a single valid BLE
auth is enough to wipe or (in principle) brick the watch. Handle with care.✅ Hardware exposed via BLE:
WORKOUT_GPS) and location push (GPS_PUSH).There is no barometer/altimeter, compass, gyroscope, or skin/body-temperature sensor. An internal
NTC thermistor (board/battery temperature) exists but is readable only via the AT channel
(AT GETNTCTEMP, §14) — the 0155 skin-temp history stream is empty on this SKU.
Data-widget hijacks (there is no real complication/data-binding API — see §11.5): the watch's
existing text fields can be repurposed to show glanceable external data. Proven ✅: the weather
city string (WEATHER_SET_1, e.g. "BRA 2x1 ARG" appeared on the widget) and the music
track/artist fields; the contacts list (20 × name[32]+number[25]) works as a scrollable data
panel. All are pushes, not persistent complications.
77d4ff01 / 77d4ff02)A separate plain-text AT command channel, independent of the framed protocol. ✅ tested live:
AT GETSECRET (16-byte pairing secret), GETVERSION, GETSN, GETNAME, GETPID,
GETBATLV (raw mV, e.g. 3853mv), GETGSENSOR (raw accel in g, X=… Y=… Z=…),
GETNTCTEMP (°C, internal NTC).AT SETMOTOR=1 (vibrate the motor), SETHR/SETHRV/SETSPO2=… (sensor test
injection), SETLCDSWITCH/SETGPSSWITCH/SETKEYSWITCH.Replies end in ,OK. SET* commands generally execute but may not echo ,OK over BLE — confirm
case by case.
Some features are present in the firmware but disabled by SKU/region and are not reachable from the phone/BLE — they need a firmware mod, which is out of scope here:
ux2sys feature id 0x9e, seeded from NVRAM/EFUSE/region at boot; on
this SKU support flag 908b = 00. Not influenceable by phone, account, or BLE (confirmed by
experiment + RE). The app is only a relay; audio goes phone → Nothing cloud.Not needed to build a BLE client — included for completeness. The firmware's dial renderer binds each
complication slot to a numeric getter id (142-entry dispatch table). Selected ids:
0x07 hour, 0x0a combined clock angle, 0x0b minute, 0x0f second, 0x18 day-of-week,
0x19 heart rate, 0x1b battery %, 0x24 temperature, 0x36 steps,
0x70/0x71/0x72 hour/minute/second hand angle, 0x25–0x27 goal %. Ring/arc complications index a
pre-rendered frame sheet (e.g. 50 % = frame 50 of 100), not a per-pixel arc — the frames are baked
into the .bin you send (§11.5), so no external RES pack is needed.
QUICK_CARD (906D) ✅The watch's home tiles. The phone only chooses which tiles show and in what order — tiles are
rendered by the firmware (no content channel). First payload byte = sub-command: 00 = GET, 01 =
SET; both use 0x906D (0x906C is listed but not used — querying it times out). Reply = A06D.
🛑 Sending a made-up
assemblyIdwipes the watch's screens (it accepts the list, can't match the ids, shows nothing). Only send ids you read back via GET; recover via the official app or a factory reset.
GET reply ✅: status(1) ‖ 00 ‖ N(1) ‖ N × group, group = tag=01 ‖ K(1) ‖ K×(assemblyId, sportId).
Real frame: 01 00 04 01 02 5d00 6100 01 03 1900 2e00 2300 01 03 5c00 0400 5a02 01 03 4800 5100 5300
= 4 screens / 11 cards (5a02 = Sport card, sportId 2).
Slots: each screen has 4 slots. A card's type sets its size — circular/square = 1 slot,
rectangle = 2 slots. Validation is pure slot arithmetic (Σ ≤ 4 per screen); no mutually-exclusive
cards. sportId is 0 except on Sport cards (87–91). Ids 64 and 95 don't exist.
assemblyId catalog (each logical type = a contiguous range of 6 style variants _0.._5;
0 = empty slot):
Byte layouts above were reconstructed from the firmware (1.0.0.73), the official APK (3.5.7), and
decrypted live captures against a real device. The reference implementation for this project lives in
core-rust/src/{commands,frame,crypto,health,session}.rs (Rust) and the cmftool/ Python tools
(pair.py, session.py, wf_codec.py, upload_custom.py, …).
| Scopo | Servizio | Caratteristica | Proprietà |
|---|
| Scrittura comandi | 0000fff0-0000-1000-8000-00805f9b34fb | 0000fff2-… | Write |
| Notifica comandi | 0000fff0-… | 0000fff1-… | Notify |
| Scrittura shell (AT) | — | 77d4ff01-2fe2-2334-0d35-9ccd078f529c | Write |
| Notifica shell (AT) | — | 77d4ff02-… | Notify |
| Scrittura dati bulk | — | 02f00000-0000-0000-0000-00000000ffe1 | Write |
| Notifica dati bulk | — | 02f00000-…ffe2 | Notify |
| Nome | 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 |
| Nome | 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 |
| Nome | 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 |
| Nome | 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 |
| Nome | 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 🔎 (FC/cal/passi in tempo reale) | FFFF 9078 / FFFF A078 |
| FEMALE_CYCLE_SET / _RET | FFFF 9071 / FFFF A071 |
| SLEEP_CONFIG_SET / _RET (minuti target) | 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 |
| Nome | cmd1,cmd2 |
|---|
| DIAL_COMMAND_SET / _RET (elenca/riordina/seleziona) | FFFF 9055 / FFFF A055 |
| DIAL_CONFIG_SET / _RET | FFFF 9075 / FFFF A075 |
| CHANGE_DIAL (⚠️ inerte su 1.0.0.73 — non usare) | 009F 0001 |
| QUICK_CARD_SET/GET / _RET (entrambi su 906D) | FFFF 906D / FFFF A06D |
| Nome | 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 🔎 (vuoto su questo SKU) | 0155 0001 / 0155 0002 |
| WORKOUT_SUMMARY / _V3 | 0057 0001 / 0160 0001 |
| WORKOUT_GPS | FFFF A05A |
| Dominio | INIT1 req/reply | INIT2 req/reply | CHUNK req/write | FINISH ack1/ack2 |
|---|
| Watchface (foto) | 8052/0052 | 9063/A063 | A064/9064 | A065/9065 |
| Watchface (strutturato/switch) | 8052/0052 | 9075/A075 | A064/9064 | A065/9065 |
| Firmware | 9052/A052 | 9040/A040 | A042/9042 | A041/9041 |
| AGPS/EPO | 905E/A05E | — | A05F/905F | A060/9060 |
| Offset | Dimensione | Campo |
|---|
| 0 | 4 | timestamp (s epoca) |
| 4 | 4 | passi |
| 8 | 4 | distanza (m) |
| 12 | 4 | calorie |
| 16 | 16 | riservato (osservato 0) |
| Offset | Dimensione | Campo |
|---|
| 0 | 4 | inizio_sessione (epoca, UTC) |
| 4 | 4 | risveglio (epoca, UTC) |
| 8 | 2 | totale_profondo_s |
| 10 | 2 | totale_leggero_s |
| 12 | 2 | totale_rem_s |
| 14 | 2 | totale_veglia_s |
| 16 | 2 | ⚠️ [incerto] (id sessione/punteggio? i valori osservati non corrispondono alle somme dei record) |
ac 49 1f 0100D5 0001) ✅: N × 57 byte = nome(32) ‖ telefono(25). L'interfaccia dell'orologio mostra fino a 20.0063 0001) ✅ — corregge Gadgetbridge (che metteva l'etichetta alla fine,
0xff-padded — sbagliato). 40 byte per sveglia, big-endian:
secondiDelGiorno(i32) ‖ indice(u8) ‖ abilitata(u8) ‖ maschera-ripetizione(u8) ‖ flag(u8) ‖ label[32] UTF-8.
La label è all'offset 8 e appare sull'orologio. ripetizione = maschera giorni feriali (0 = una tantum);
flag è ⚠️ [incerto] (marcatore una tantum?). Esempio (13:30, idx 2): 0000bdd8 02 01 15 00 "Sveglia…".005E 0001) ✅ — l'app ufficiale e l'implementazione di riferimento usano il
DailyTargetBean v1 a 10 byte, big-endian: passi(u32 BE) ‖ distanza_m(u32 BE) ‖ calorie_kcal(u16 BE). (Questa è la forma di Gadgetbridge; precedenti rapporti che l'orologio "ignorasse"
il comando erano un bug di decifratura di sessione vecchia, non un problema di payload.) 🔎 La RE del firmware mostra anche una variante
estesa più lunga a 29 byte (aggiunge sleep_min/exercise_min/stand_h + 6 flag di abilitazione, tutti u32
BE dopo un prefisso flag(u16 LE), con range imposti: passi 2000–30000, dist 1000–99000, cal
100–5000, sonno 360–720, esercizio 30–90, stand 6–16) — non il percorso predefinito dell'app; preferire la
forma a 10 byte a meno che non servano i target extra.0060/0061 0001) ✅: 11 byte:
abilitato(1) ‖ soglia_min(u16 LE) ‖ dndStart(u32 LE) ‖ dndEnd(u32 LE). Notare che la "finestra attiva
08:00–22:00" mostrata nell'interfaccia è un valore predefinito fisso del firmware e non è trasportato nel payload.00DC 0001) ✅: conteggio(1) = 36 slot ‖ activityTypeCode[36] (codici attivi poi 00
padding). Seleziona quali sport appaiono nel menu allenamento dell'orologio.009B 0001) ✅: byte kind — 01 = HR 24/7, 02 = SpO₂,
04 = stress (misurato ogni 30 min).FFFF 9059) ✅: disabilitato = 00; abilitato =
01 ‖ hrLow ‖ hrHigh ‖ sportHrHigh ‖ spo2Low ‖ 00 00 00 00 (un limite 0/255 = "nessun limite").FFFF 9071) ✅: 01 ‖ predictionOpen ‖ notifySwitch ‖ cycleStartSwitch ‖ cycleStartNotifyBefore ‖ ovulationStartSwitch ‖ ovulationStartNotifyBefore ‖ fertileStartSwitch ‖ fertileStartNotifyBefore ‖ periodo(1) ‖ cicloPeriodo(1) ‖ dataInizioCiclo(u32) ‖ markStart(u32) ‖ markEnd(u32) (catturato: periodo=5, cicloPeriodo=0x1c=28).FFFF 9073) ✅: TLV — conteggio(1) ‖ totale(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]…
(7 risposte predefinite catturate e decifrate).FFFF 906F) ✅: invia ID città numerici, non nomi (01 ‖ conteggio ‖ cityId(2 BE)…);
l'orologio mappa gli ID da una tabella interna. Config DST FFFF 9083 =
conteggio ‖ [id(u16 LE) ‖ dst(u16 LE) ‖ inizio(u32 LE) ‖ fine(u32 LE)]….FFFF 905C, 131 B) ✅: stato(1: 0=nessuno/1=in pausa/2=in riproduzione) ‖ volume(1) ‖ volumeMax(1) ‖ traccia(64) ‖ artista(64). L'orologio invia anche MUSIC_BUTTON (A05D) indietro.FFFF 906B, 199 B) ✅ — usare questo: 7×9 byte giorni + 24×2 byte ore +
città(32) + 7×8 byte alba/tramonto (LE). Temperature codificate come (temp_c + 100) & 0xFF.
⚠️ Lo stesso payload inviato su WEATHER_SET_2 (0066 0001) non aggiorna il widget meteo su
Pro 2 — usare sempre 906B. (La stringa città è anche un vettore provato per dirottamento dati — vedi §13.)005D 0001) ✅: payload 0x01 → l'orologio squilla/vibra (+ ACK 005D 0003).FFFF 906A) ✅ — big-endian, longitudine prima: 16 byte
ts(u32 BE) ‖ lon×1e7(i32 BE) ‖ lat×1e7(i32 BE) ‖ 00 00. Validato per una posizione reale.FFFF A05A) ✅ — little-endian, longitudine prima: 12 byte
ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32).FFFF 8004): vedi §7.| cf | bpp | raster (dopo LZ4) | uso |
|---|
| 4 | 2 | RGB565-LE | sfondo opaco (FULL/THUMB) |
| 5 | 3 | RGB565-LE (2 B) + alpha (1 B) per px | sprite anti-alias (glifi, mani, icone) |
| 13 (0x0d) | 0.5 | maschera alpha a 4 bit; il firmware sfuma a runtime | atlante glifi cifre |
| 24 (0x18) | 4 | RGBA8888 | livelli a colori reali (incl. il sempre acceso aodImage) |
| 1 | — | JPEG/JFIF (ff d8 ff), estraibile con qualsiasi decoder | rari fotogrammi di animazione |
0a0x61NotEnvelope0aChildOverflow:61 0a 00−18/−160a.bin. ⚠️ A configurable
complication is authored as N 0x68 group nodes stacked at the same (x,y), each bound to a
different source (275's two circles: 0x1e/0x6a/0x48/0x24/0x19 per slot — option/style ids, not
the shown metric); the two circles are byte-identical apart from their rect + an instance byte
(0x79/0x7a). Which metric shows (STEPS vs KCAL vs …) is device RAM/config state, so a
static preview cannot reproduce it from the file — best-effort only.(446,0), seen on 275/302/325/365/375)
are slots the firmware doesn't draw in the default view — their value can't even fit before the
canvas edge. Treat as hidden in the preview.0x22aod0x22@69,2090x60 img_number (cnt=10) — source at −5, off-by-one forward. ✅ Same off-by-one
as §11.8 but for non-clock numbers: "Gradient"'s date sat at (203,80) top-center with source
0x17, but the forward 82-scan grabbed the neighbouring pointer's angle getter (0x0a) and
the pointer's position → the number rendered at the pointer's spot with a bogus source. Fix: for a
61 0a 00 img_number in a 0x60 wrapper, trust −5/−18/−16 when the forward source is
impossible for a number (source-0 or a pointer-angle getter 0x0a/0e/12/70/71/72) and the
−18/−16 position is valid & non-zero (the non-zero guard skips group-child digits with relX=0).0x17 = date (day of month), 0x24 = temperature — distinct. Dial 340 uses both (0x17
"Jun 09" and a separate 0x24 temp), so 0x17 is date, not temp. A dial whose watch shows a
temperature in a 0x17 slot is a user-configured complication (device state), not the file default.| dec | card | dec | card |
|---|
| 0 | empty slot | 49–53,97–98 | Weather |
| 1–6 | Steps | 54–58 | Timer |
| 7–12 | Calories | 59–62 | Breathing |
| 13–18 | Stand | 63,65–67 | Stopwatch |
| 19–24 | Moderate activity | 68–71 | Battery |
| 25–30 | Heart rate | 72–76 | Recents |
| 31–36 | SpO₂ | 77–81 | Contacts |
| 37–42 | Stress | 82–86 | Dial / phone |
| 43–48 | Sleep | 87–91 | Sport (sportId ≠ 0) |
| 92 | Music | 93/94/96 | Activity record / PAI / Cycle |