
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.
Non ufficiale. 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 né approvato da Nothing/CMF. Utilizzo a proprio 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:
Dove una sezione successiva corregge una precedente, il testo precedente viene mantenuto con un riferimento piuttosto che eliminato — sapere quali letture sono state provate e confutate evita alla persona successiva lo stesso giro.
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 periferico, che si pubblicizza come CMF Watch Pro 2-XXXX
(4 caratteri esadecimali).
Abilita le notifiche scrivendo 01 00 su ogni CCCD (00002902-…). Il canale comandi
(fff1/fff2) trasporta il protocollo incapsulato descritto di seguito. Il canale shell (77d4…) trasporta
testo in stile AT semplice (es. AT GETSECRET; vedi §14). Il canale dati (02f0…) trasporta grandi blob
binari (watchface, firmware, AGPS), coordinati da opcode di controllo sul canale comandi.
UUID dei servizi — l'orologio pubblicizza ~10 servizi primari. Enumerati su un'unità reale:
0xfff0 (comandi), 0x180f (batteria), 0x180a (info dispositivo), 0xefe7, 0xffd0,
02f00000-…ffe0 e 02f00000-…fe00 (dati), 77d4e67c-2fe2-2334-0d35-9ccd078f529c (shell /
accoppiamento), e49a3001-f69a-11e8-8eb2-f2801f1b9fd1, f48a23c0-f69a-11e8-8eb2-f2801f1b9fd1.
⚠️ L'UUID del servizio shell è
77d4e67c-…, non77d4ff00-…. Revisioni precedenti di questo documento assumevano che il servizio condividesse il prefissoff00delle sue caratteristiche (77d4ff01/77d4ff02, §14) — non è così, almeno sull'unità su cui è stato verificato (risultato da freethinkel/fmc, vedi §Fonti). Gli UUID delle caratteristiche sono invariati. Non verificato se77d4e67csia stabile tra le unità — enumerare piuttosto che hardcodare.
🌐 Nota su Web Bluetooth. Chromium scopre solo i servizi che la pagina ha elencato in
optionalServices, anche per una chiamatagetPrimaryServices()non filtrata — una pagina che elenca 3 servizi ne vede 3, mentrechrome://bluetooth-internals(il livello C++ di Chrome, non limitato) mostra tutti e 10. Se scrivi un client browser, elenca tutti gli UUID sopra in anticipo o l'accoppiamento fallirà con servizi che esistono chiaramente. Nessun Web Bluetooth in Firefox/Safari; richiede un gesto dell'utente + HTTPS/localhost.
✅ Un'intera sessione reale è stata eseguita sul solo canale comandi — durante una cattura di 160 s di uso intensivo non c'è stato traffico sui canali dati/firmware o shell tranne durante un trasferimento esplicito OTA/watchface.
0xF5)Ogni messaggio sul canale comandi è incapsulato 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 frame builder
dell'app ufficiale (`C6117b.m30831g`).
- `chunkCount` = numero totale di chunk per questo comando; `chunkIndex` è **1-based**.
- `chunkLen` = numero di byte di `chunk` in questo frame.
- Una singola scrittura BLE può essere frammentata dall'MTU del link; il ricevitore bufferizza i byte
grezzi e ri-estrae i frame completi. I payload di grandi dimensioni vengono suddivisi in più chunk
(stesso `cmd1/cmd2`, `chunkIndex` crescente) e riassemblati in ordine.
### Convenzione opcode (✅ confermata sul wire)
- `cmd1 = 0xFFFF`: `cmd2` in `0x80xx`/`0x90xx` = telefono→orologio (richiesta/impostazione); `0x00xx`/`0xa0xx` =
orologio→telefono (risposta). Le coppie corrispondono tramite 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.
**Stranezza del plaintext:** 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 trasportano il CRC all'interno del ciphertext come di consueto.)
Dimensionamento dei chunk (così i chunk cifrati cadono 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` dei frame cifrati osservati erano multipli di 16 (l'allineamento dei blocchi è rispettato).
---
## 3. Primitive crittografiche
- **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 è impostata, tutti i frame del canale di comando sono crittografati AES tranne gli opcode in chiaro elencati in §5.
✅ Entrambe le derivazioni validate: authkey recuperato dal ntwatch.db di un telefono rooted corrispondeva al
valore derivato da un rnd1/rnd2/secret catturato; sessionKey riprodotto da un nonce catturato
decrittografa 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 di mancata corrispondenza della firma, l'autenticazione fallisce.
### 4.2 Riconnessione (authkey già noto)```
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 intatto in una cattura reale.
⚠️→✅
TIMEè obbligatorio prima delle query sui dati. DopoInitialized, l'orologio non risponderà aBATTERY,SERIAL_NUMBER_GETo all'handshakeACTIVITY_FETCH_*finché 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; inviando primaTIME→ batteria e seriale iniziano a rispondere.
Ordine consigliato per la fase 2: TIME → FIRMWARE_VERSION_GET → SERIAL_NUMBER_GET →
BATTERY (0xA5) → push di configurazione → sincronizzazione salute (§8).
Non esiste un opcode "read" 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) trasportando il valore corrente.
I comandi SET vengono confermati con cmd2 = 0x0003 e corpo vuoto.
I frame sono crittografati AES una volta impostata una chiave, eccetto questi opcode, che sono sempre in testo 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 richiedono 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 dei payload è ⚠️ [incerta].
Quadranti / firmware / AGPS usano un ciclo init → richiesta/scrittura chunk → ack finale:
(tutti con cmd1 = FFFF.) L'orologio guida il ciclo emettendo DATA_CHUNK_REQUEST_*(offset, length)
(offset/length = u32 big-endian); il telefono risponde con DATA_CHUNK_WRITE_* che trasporta
payload[offset..offset+length] sulla caratteristica dati. Vedi §11–§12 per i dettagli.
Payload di TIME (FFFF 8004) = epochSeconds(i32, BE) ‖ utcOffsetMillis(i32, BE). Inviato subito dopo
l'autenticazione così l'orologio mostra l'ora locale (e sblocca le query sui dati — vedi §4.3).
⚠️ I timestamp sanitari dell'orologio sono UTC. L'app companion deve aggiungere l'offset UTC locale prima di derivare il giorno di calendario / l'ora del giorno locale. (Raggruppare la salute per giorno UTC grezzo fa scattare il giorno all'ora locale sbagliata.)
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 poi invia una raffica di frame dati:
ACTIVITY_DATA, HEART_RATE_*, SPO2, STRESS, SLEEP_DATA, WORKOUT_SUMMARY[_V3].La sincronizzazione è sequenziale (deve seguire TIME; l'orologio rilascia gli stream dopo ACK_2), non un'unica
raffica. Una sessione pesante invia ~170–210 frame di notifica in ~160 s. ✅
ACTIVITY_DATA (32 byte ciascuno, LE) ✅Unità calorie: le calorie dell'attività sono riportate in cal (calorie-grammo). Dividi 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 di stress).00DA 0001) è diversa — 5 byte: timestamp(i32 LE) ‖ fc(u8).
✅ esempio dal vivo 5e dc 29 6a 4e → ts, fc = 78 bpm. Intervalli punteggio stress: 1–29 / 30–59 / 60–79 / 80–99.SLEEP_DATA (intestazione 18 byte + N × record 8 byte) ✅Un SLEEP_DATA = una sessione di sonno; una notte può contenerne diverse (i micro-risvegli dividono le sessioni).
Intestazione:
Ogni record di 8 byte: timestamp(u32) ‖ durata_s(u16) ‖ fase(u16).
Codici fase: 1 = Profondo, 2 = Leggero/centrale, 3 = REM, 4 = Sveglio. ✅ validato su una notte intera
(due sessioni, i totali D/C/R/A riconciliano).
WORKOUT_SUMMARY v1 (54 byte) / _V3 (0160 0001)v1: inizio(u32), fine(u32), durata_s(u32), poi tipo/calorie/passi/distanza/FC-media 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 Room DB dell'app) ma gli
offset esatti dei byte dentro quel blocco di 40 byte sono ⚠️ [incerti] — chiuderli richiede una cattura grezza
di un allenamento GPS.
Le stringhe sono UTF-8, troncate a byte alla dimensione del campo (il troncamento può dividere un carattere
multi-byte, corrispondendo al comportamento s.encode()[:max] del firmware); i campi corti sono riempiti con zeri 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 = livello(1) ‖ inCarica(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 blocca tutto (§4.3) — invialo per primo o l'orologio resta muto sulle query dati.GOALS_SET e
GPS_PUSH sono big-endian, e offset/length del trasferimento in blocco sono big-endian.L'orologio supporta (a) quadranti foto/personalizzati (un'immagine di sfondo + un orologio digitale disegnato dal firmware) e (b) quadranti strutturati (sfere integrate / dello store: uno sfondo più layer di sprite posizionati, lancette e widget di testo). Entrambi trasferiti sul canale dati tramite il ciclo init → chunk in §6.
Cosa funziona davvero (✅ validato dal vivo): creare un quadrante foto da qualsiasi immagine e installarlo; installare offline uno qualsiasi dei 103 quadranti dello store; reskinning di un quadrante strutturato (scambia lo sfondo o qualsiasi sprite non di sfondo) e spostare i suoi layer; riordinare / cambiare la sfera attiva; e creare un quadrante strutturato da zero — l'involucro scena
0x20è decodificato e il builder è implementato (§11.7), provato offline a fare round-trip di tutti i 103 quadranti dello store byte per byte e a 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 strutturale offline copre già ciò che causava il rifiuto0a). Non c'è barriera di codec o trasporto e nessuna necessità della toolchain del vendor. Le vecchie affermazioni "il render strutturato è cotto nel RES-pack / impossibile via BLE" e "codec lato server cf=0x1f" 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) ‖ selectIndex(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) — 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)
Finish reply byte: `01` = attivato e salvato; `0a` = memorizzato ma **non** attivato / rifiutato. Su
Android ogni `DATA_CHUNK_WRITE` deve uscire come **una scrittura BLE per frame** — concatenare e
ri-tagliare in base all'MTU desincronizza gli header e l'orologio continua a richiedere l'offset 0.
- **`9063` (foto) = APPEND.** La lista dei quadranti cresce (6→7); `watchfaceId = 0xFFFFFFFF` (sentinel
personalizzato) così non viene mai rifiutato come duplicato, e l'orologio lo attiva automaticamente.
- **`9075` (strutturato) = SOSTITUISCE** lo slot `old_id`. `old_id` **deve** essere già nella lista
(altrimenti `0a`). Per reinstallare un id già presente, **eliminalo prima** (9055 lista-meno-id)
poi carica "da zero" — riutilizzare un id già presente dà `0a`.
### 11.3 Quadrante foto / personalizzato — ✅ completamente validato 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 su RGB565 little-endian, top-down (payloadLen conta dal
primo byte LZ4). L'app ufficiale usa LZ4-HC e rimuove l'header/footer di 21 byte del blocco LZ4; funziona
anche un encoder LZ4 solo-literals — l'orologio accetta qualsiasi LZ4 valido, l'identità dei byte non è
richiesta. I pixel fuori dal 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 del `.bin`; `FFFFFFFF` = `watchfaceId` personalizzato; `styleId` 0–4 seleziona il layout
dell'orologio digitale integrato (viene sempre disegnato — non esiste uno stato "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 esito `0a` — usa l'header completo sopra. (Implementazione di riferimento:
`core-rust/engine.rs::build_wf_init2`, che rispecchia `C6135t.m31104u` nell'app ufficiale.)
**Procedura:** ridimensiona l'immagine a 466×466 (e una miniatura 270×270), converti in RGB565-LE dall'alto verso il basso,
aziona facoltativamente i pixel fuori dal cerchio, comprimi ciascuna 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 / da store — contenitore e codec ✅
**Struttura del file** — l'header di 36 byte viene ripetuto **byte-per-byte come footer di 36 byte** alla fine del file
(✅ verificato su 15 quadranti; un parser dovrebbe rifiutare un file in cui differiscono):```
[36-byte header][scene TLV (§11.7)][asset pool][36-byte header again]
Header (struttura identica in tutti i 103 dial dello store; tutti i campi in little-endian):``` 0x00 crc_tree u32 LE [CRC32-raw of header[0x04:0x24] ‖ scene section] ✅ see below 0x04 magic 01 00 00 XX [XX = 0x00 or 0x02; both seen, meaning of 0x02 unknown] 0x08 name char[16] [NUL-terminated, e.g. "SlopeTime", "Metaball"; may carry a non-zero tail after the NUL (@0x17) — round-trip it verbatim] 0x18 size_a u32 LE [= filesize − 36 = footer offset = header+body] ✅ 103 dials 0x1c size_b u32 LE [asset-pool length, exactly] ✅ 15 dials 0x20 crc_assets u32 LE [CRC32-raw of the asset pool] ✅ see below 0x24 … [body starts here: the 0x20 scene container, §11.7]
> ⚠️ **Correzione (sostituisce "non esiste checksum bloccante").** Revisioni precedenti leggevano `@0x00` come
> un id/hash per-dial e `@0x20` come "3× parole u32 id/hash \[non un CRC]", e affermavano che CRC32/Adler32/
> byte-sum non corrispondono. Entrambe le parole **sono** CRC32 — i test precedenti le hanno mancate perché
> la variante non è standard, e perché la lettura "3 parole a `0x20`" confondeva l'unica parola CRC
> con i primi byte del contenitore della scena che inizia a `0x24` (allo stesso modo "nome ripetuto a
> `0x2c`" è il nodo nome `0x86` della scena, §11.11). Scoperta da
> [freethinkel/fmc](https://github.com/freethinkel/fmc); riverificata qui.
**CRC32-raw** = polinomio IEEE riflesso `0xEDB88320`, **`init = 0`**, e **nessun XOR finale** — cioè
né l'`init=0xFFFFFFFF` né il `^0xFFFFFFFF` del `crc32` standard. Questo è il motivo per cui
il CRC32 pronto all'uso non ha mai corrisposto. Nota la dipendenza dall'ordine: `crc_assets` si trova all'interno dell'intervallo
coperto da `crc_tree`, quindi **scrivi `@0x20` per primo, poi calcola `@0x00`**.```python
def crc32_raw(data: bytes) -> int: # tab = standard 0xEDB88320 reflected table
c = 0 # init 0, no final inversion
for b in data: c = tab[(c ^ b) & 0xFF] ^ (c >> 8)
return c & 0xFFFFFFFF
crc_tree = crc32_raw(f[0x04:0x24] + f[0x24:first_asset])
crc_assets = crc32_raw(f[first_asset:len(f)-36])
Verificato: 9/9 dial memorizzati intatti corrispondono su entrambe le parole, e 6/6 dei template di questo repo corrispondono su crc_tree.
🟡 Il firmware non sembra applicare nessuno dei due CRC. Ogni dial che questo repo ha installato su
9075— incluse le reskin prodotte dal percorso di modifica in-place con stessa footprint (§11.6), che muta i payload degli asset e i byte X/Y senza ricalcolare l'header — è stato renderizzato correttamente sul dispositivo. Quindi un CRC obsoleto non è ciò che causa un rifiuto0a(quello è l'invariante della finestra del contenitore, §11.7). Tratta i CRC come scrivi-corretto-comunque: economici, e l'unico campo di integrità noto nel formato. Qualsiasi cosa riscriva la scena o il pool di asset dovrebbe ricalcolare entrambe le parole.
Dial stub (~173 B, es. id 273/274/277) sono segnaposto per quadranti cotti nella ROM: header + directory, nessun asset reale.
Asset — ognuno è dimsWord(u32 LE) ‖ len(u32 LE) ‖ LZ4(payload), dove
cf = dimsWord & 0x1f, w = (dimsWord >> 10) & 0x7FF, h = (dimsWord >> 21) & 0x7FF, e len
conta dal primo byte LZ4 (il 1f 00 01 00 che vedi spesso lì è il primo token LZ4 — non
saltarlo). Dimensione decompressa = w·h·bpp:
✅ Tutti i 4151/4151 asset sui 103 dial decodificano esattamente con un decompressore standard lz4.block
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 cifrato AES:```
kind(1) ‖ old_id(u32 LE) ‖ new_id(u32 LE) ‖ file_len(u32 LE)
`kind` = `0x02`/`0x03`; `old_id` = dial attivo corrente (da `9055`); `file_len` = dimensione reale del `.bin`
(= `@0x18 + 36`). Installare un `.bin` dello store così com'è è il percorso garantito (Ring Data id 359 +
102 altri confermati). (Riferimento: `core-rust/engine.rs::build_dial_replace_init`.)
### 11.5 Grammatica strutturata della directory ✅ (decodificata e implementata — REVISATA 2026-07-02)
> **⚠️ Revisione (2026-07-02): lo schema piatto del record `61 01 00` qui sotto era sistematicamente
> SBAGLIATO DI UNO.** Il corpo della scena è un TLV pulito (§11.7); un **corpo di foglia** disegnabile (tag `0x30`/`0x38`
> statici, puntatore `0x70`) è:
>
> ```
> 01 xx 00 [X u16][Y u16] …attrs… 61 [count u16][base u32][count×id u16] [05 05 00 01 pivX pivY]
> ```
>
> - l'attributo `0x01` apre il corpo: **X,Y = angolo in alto a sinistra** sulla tela 466² (gli `s16 x,y` dell'SDK
> `sty_picture_t`).
> - la **tabella dei frame `61 …` chiude il corpo** (`base` = puntatore asset; `count` 1 = immagine, 10/11 =
> atlante delle cifre — il vecchio "tipo di record `0a/0b`" era in realtà questo count! — 7/13/2 = foglio
> dei frame della complicazione).
> - extra del puntatore: sorgente+scala `[src] 00 3c 00` dentro l'attributo `0x01`; pivot nel
> **trailer** `05 05 00 01 [pivX][pivY]`. **Centro di rotazione = `(X+pivX, Y+pivY)` per puntatore** —
> non un (233,233) fisso: esistono subdiali fuori centro (es. le lancette del dial 366 ruotano attorno a 150,150).
>
> La scansione lineare per `61 01 00` cuciva la tabella dei frame+pivot dell'elemento **N** alle X/Y
> (e al byte di tag, il vecchio "f3") dell'elemento **N+1** — sembrava *giusta* solo sui dial analogici i cui
> lancette adiacenti condividono geometrie quasi identiche. Il "muro della variante compatta" (spec 24 §24.4.5) era
> questo stesso fraintendimento. Implementato come `scan_scene_drawables` in `core-rust/watchface_struct.rs`
> e `wfweb/src/codec/parse.ts` (scena = fonte primaria per immagini/puntatori; scansione piatta mantenuta per
> testo + fallback non-envelope). Validato dall'oracolo `wfweb/compare.html` (render vs PNG ufficiali dello store, 99 dial): 64→72 buoni, 8→5 cattivi, diff media 9.3→7.4%.
Lettura storica del record piatto (superata, 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 in alto a sinistra sulla tela 466² = `(X−pivotX, Y−pivotY)`.
- **Puntatore/lancetta** — stesso record immagine, ruotato a runtime. **Centro di rotazione = `(X+pivotX, Y+pivotY)`**
(≈ 233,233 sui dial analogici). La **sorgente dati è un `u8` all'offset del 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 disassemblando i getter (fallback RTC 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"; la cifra *d* = l'asset a
`index("0") + d` (10 sprite cf=5 consecutivi, es. `0123456789` e punteggiatura `,°`). ✅ renderizzato.
- **Riempimento complicazione = indice del frame** (✅ confermato per complicazioni a cifre/enum/gauge, count>1): il
valore indicizza un **foglio di frame pre-renderizzato** nel `.bin` — `frame = (count−1)·val/100` (percentuale) o
`frame = value` (cifra flip / enum). Tabella dei frame = sotto-record `61 ‖ count(u16) ‖ base(u32) ‖
count×id(u16)`. Es. l'ora grande di 327 Digit Max è un foglio a 13 frame (numeri 0–12), `frame = hour`.
- **Anello di avanzamento / arco = clip settore runtime** (✅ 2026-07-02, **corregge la lettura "gli anelli sono fogli di frame"
nella spec 25 §2**): il tag dell'elemento **`0x81`** porta un **singolo** disco pieno (`61` tabella dei frame
`count == 1`), e il cuneo parziale è quel disco **ritagliato a settore circolare** (`frac = value/max`,
in senso orario dalle 12) — verificato pixel per pixel su 322 Glare 2 e confermato `count==1` su
**20 dial**. Su disco: corpo `0x81` = sotto `0x01` (geometria `x@+0 y@+2 w@+4 h@+6`, `61 1 base` inline
= disco) + sotto `0x5b` (specifica arco). Implementato in wfweb (`blendSector`).
⚠️ Il sotto-record `0x5b` **non** è solo "`max` u16 `@+4`" come documentato in precedenza — quello leggeva la
metà bassa di un `max i32` e perdeva gli **angoli di inizio/fine sweep e la larghezza del tratto** che stanno
subito dopo. Esiste anche un fratello procedurale `0x80`/`0x5a` (con raggio esplicito) che questo
documento non ha mai coperto. Layout completo del record, e cosa l'assunzione precedente "in senso orario dalle 12"
sbagliava: **§11.15**.
- **Id della sorgente dati** — il blocco attributi `82` dell'elemento sta a `delim+3` (dopo l'ultimo `40 01 00`),
e **l'id della sorgente è un `u8` a `+0x14`** (anche `relX@+0x07 s16`, `relY@+0x09 s16`, `anchor@+0x0C/0E`,
`mode@+0x15`, `frame-count@+0x1A`). Anchor < 0 = allineamento al bordo del genitore. 🔎 Il firmware risolve
l'id tramite una tabella di getter a 142 voci a `0x101f371c` (ognuna chiama `ux2sys_get(type)`).
⚠️ **Preferisci leggere l'id come `meta[9]` della struct (§11.11)** — un campo fisso — rispetto a questa
scansione in avanti dell'attributo `82`, che è la fonte dell'errore di uno descritto in §11.8/§11.9. Tabella id completa in §16; nota
che le etichette salute/meteo elencate inline in questa sezione (`0x19` FC, `0x1b` batteria,
`0x24` temperatura, `0x36` passi) sono **contestate e probabilmente sbagliate** — vedi il riquadro ⚠️ in §16.
(L'esempio del gruppo `0x07:0x0b:0x0f` = HH:MM:SS qui sotto non è influenzato.)
- **Nodo gruppo** (`0x68`): annida i suoi figli dentro il 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 di salto indicizzata da `tag−0x70`).
### 11.6 Matrice di authoring
| Percorso | Stato | Note |
|---|---|---|
| Dial fotografico da qualsiasi immagine | ✅ **fatto** | §11.3; validato sul dispositivo |
| Installare uno qualsiasi dei 103 dial dello store | ✅ **fatto** | §11.4; `9075`, `old_id`=attivo |
| Reskin del background cf=4 di un dial dello store | ✅ **funziona live** | scambia il payload FULL in posizione, imposta la `len` dell'asset alla **nuova** dimensione del blocco (≤ vecchia), mantieni la stessa impronta del file, installazione pulita |
| Ri-authoring tramite template (scambia i pixel di qualsiasi layer + sposta la geometria) | ✅ **renderizza via BLE** | dial 373: bg→ciano + uno sprite cf=5→rosso + X spostato 224→100, tutto renderizzato, lancette live |
| Dial strutturato 100 %-sintetico da zero | ✅ **builder fatto, validato offline** | builder envelope `0x20` in `watchface_struct.rs` (`build_container`/`serialize`/`validate_container`); round-trip byte-exact su tutti i 103 dial + sintetico supera il validator del firmware (§11.7). 🟡 render on-device su `9075` non ancora filmato |
| Font di sistema (`.font`) | ✅ **decodifica/render (tutti)** | bin LVGL (non proprietario); 32 font numerici (`num*/nm*`, non compressi) + 24 font di 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 + prefilter XOR per riga), portato 1:1, nessun disasm |
⚠️ Insidie di reskin/ri-authoring che causano schermo nero o `0a`: lasciare la **vecchia `len` dell'asset** (l'orologio
legge oltre il blocco → overrun → nero); **ingrandire il file** (rifiutato all'installazione); riusare un id
**in posizione** invece di un'installazione pulita; **riordinare gli asset** senza correggere la catena
delle dimensioni dei blocchi della coda di riferimenti (§11.11). Non fatale oggi ma scrivilo comunque correttamente: qualsiasi modifica alla scena o al pool di asset
invalida le due parole **CRC32** dell'header — ricalcola entrambe (§11.4), `@0x20` prima di `@0x00`.
### 11.7 L'envelope di scena `0x20` — decodificato e builder implementato ✅
Un dial **reale** ri-authorizzato renderizza perché preserva l'envelope di scena del file. Un corpo puramente sintetico
di record piatti `61 …` viene **rifiutato** — il parser del firmware (`WFManager_Parser`, `0xdb35c`)
richiede che il corpo (dall'offset `0x24`) inizi con un contenitore di 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
La scena è un TLV nidificato pulito — [tag u8][len u16 LE][body], i tag contenitore 0x20/0x21/0x22/0x68
ricorsivi, i drawable foglia 0x30 (statico) / 0x70 (elemento/puntatore) / 0x80 / 0x81 / 0x86 (nome).
(I record piatti 61 01 00 / 61 0a 00 sono pattern che vivono dentro i body dei drawable; il
vecchio parser li trovava in modo euristico — e cuciva insieme body adiacenti, vedi la revisione §11.5.
Il layout del body dei drawable è ora completamente decodificato lì.) Ogni offset+len di un figlio deve rientrare nella
finestra del genitore;
primo byte del body ≠ 0x20 → errore parser −16; un figlio che sfora la sua finestra → −2; entrambi fanno
scrivere al gestore 9065 (0xeb50c) il finish .
Il builder è implementato e validato offline (core-rust/watchface_struct.rs:
SceneNode / serialize / parse_scene / validate_container / build_container /
build_container_raw; CLI cmfwatch-wfgen reframe):
scene_roundtrip_identity — tutti i 103 quadranti dello store: parse_scene→serialize riproduce la scena
byte per byte (i len nidificati ricalcolati corrispondono) e validate_container passa su ognuno.build_reframe_identity / CLI reframe — riassemblare l'intero .bin da zero riproduce
il file byte per byte tranne 1 byte di padding del nome (@0x17; non è un checksum).build_container_synthetic — compone un nuovo quadrante (sfondo + drawable nidificato in 20→21) che
supera l'invariante esatto del firmware (build_container emette finestre nidificate corrette).validate_rejects_bad_containers — rifiuta un body piatto (→ , il bug storico )
e un figlio che sfora la sua finestra (→ ).🟡 Ancora non dimostrato (serve l'orologio, non bloccante): caricare un sintetico da zero tramite 9075
e guardarlo renderizzare — la prova strutturale offline copre già ciò che causava il rifiuto 0a.
Ho incrociato il render di wfweb con le miniature ufficiali dello store (oracolo pixel su tutti i 103 quadranti) e ho chiuso quattro lacune:
i16 (con segno). ✅ Le ancore possono essere negative per elementi che
si estendono fuori dalla tela — es. la lancetta dei secondi rossa di 275 sta a Y = 0xFFFC = −4 (uno sprite 30×281,
sorgente 0x12, ruotato dal centro oltre il bordo superiore). Leggere X/Y come u16 (65532) faceva
scartare il guard. Analizza entrambi come con segno e consenti un piccolo intervallo negativo.0x60 img_numbers di primo livello (non solo dentro un gruppo 0x68), e
la vera sorgente dati è il u8 all'offset di record −5 — la scansione in avanti dell'attributo 82 è
sistematicamente fuori di uno qui e cattura l'attributo del fratello successivo (in 275 la cifra dei minuti
prendeva il 0x18 del giorno della settimana). Il "10:10" di 275 = ora 0x07@X≈306 + min 0x0b@X≈369 con i
come statico adiacente tra loro, ciascuno un atlante di 11 glifi (). ⚠️ Quando correggi X/Y
da , anche gli , altrimenti una riesportazione corrompe quei byte (rompe
la stessa impronta → ).Inoltre: le miniature ufficiali dello store sono renderizzate alle 10:10 (classico orario di marketing), non alle 10:12 — allineare l'oracolo alle 10:10 riduce notevolmente la differenza media di pixel. Il parser di wfweb ora fa round-trip di tutti i 103 quadranti byte-esatto (la correzione degli offset di scrittura X/Y sopra ha eliminato le ultime discrepanze).
0x22 nella sua propria vista. ✅ Il walker della scena salta già 0x22,
ma la scansione piatta di testo/numero attraversava l'intero [0x30, firstAsset) — quindi emetteva la
variante always-on (AOD) di ogni elemento come layer normale. Su "Gradient" l'atlante della data grigia AOD
(offset in 0x22) si disegnava sopra quello rosso normale. Fix: tagga ogni record 0x22 con
layer.aod=true (con il suo proprio set di dedup) e lascia che renderAt(…, aod) li mostri solo in modalità AOD
(la modalità normale nasconde i layer aod; la modalità AOD nasconde quelli normali; lo sfondo è scambiato da setAod
e si disegna sempre). Vincita netta dell'oracolo in modalità normale sull'intero corpus (284: 31%→21%, +18 altri) —
le varianti AOD sovrascrivevano molti quadranti — e il toggle AOD dell'editor ora mostra il vero
layout always-on invece di quelli normali. La vera AOD è uno schermo nero (nessuna scena attenuata):
se il quadrante non ha un frame di sfondo AOD dedicato (dial.aod), la scena normale è nascosta in modalità
AOD così renderizza nero + gli elementi al loro colore. Le AOD vengono parse anche tramite il
walker della scena (ora ricorre nel contenitore taggando i drawable , invece di lasciarli
alla scansione piatta dove il loro pivot non corrispondeva → "non posizionati"); le lancette AOD ruotano al
centro della tela (il a volte porta una x/y di lancetta fuori centro che il firmware ignora — es.
l'ora di Gradient ). L'editor espone anche questo come
(§UI): ogni schermata mostra solo i propri layer e le modifiche persistono in modo indipendente. Il render in modalità
normale è byte-identico ovunque; il roundtrip resta byte-esatto su tutti i 103 quadranti.40 01 00 XX (✅ confermato dal firmware)Quante cifre disegna un img_number è un singolo byte nel record del campo — il byte dati XX
del sotto-record attributo 40 01 00 XX dell'elemento (il sotto-record 0x40 che sta dopo la
tabella frame 61 [count][base][glyph-ids]):
XX & 0x0F = numero di slot cifra (0 ⇒ default firmware 7).0x80 = zero-pad (mostra zeri iniziali, es. "09" vs "9").Confermato disassemblando il firmware (immagine XIP 0x10000000; routine di render 0x100d8e60):
NDIG = ldrb[40sub+3] & 0x0F (→7 se 0); il valore è clampato value % 10^NDIG e vengono disegnati esattamente NDIG
glifi MS-first, gli zeri iniziali soppressi a meno che bit7. Il u16 dopo la sorgente (60 per la
data, 1000 per kcal) NON è il conteggio — alimenta solo l'inserzione del glifo separatore di migliaia/milioni
(cmp #1000/#1000000), motivo per cui modificarlo non faceva nulla. L'id sorgente non limita nemmeno.
L'istogramma del corpus su tutti i 620 campi numerici corrisponde: i campi a 2 cifre (ora/min/sec/data/temp/HR) finiscono
40 01 00 02/0x82; kcal …04; passi …05; gli split di orologio a cifra singola 0x81. Quindi il campo data
40 01 00 82 = 2 cifre, zero-padded — è l'intera ragione per cui una temperatura Fahrenheit rimbalzata
(≥100) veniva troncata.
Fix / editor: wfweb analizza digitCount/digitZeroPad (+digitCountOff) per i campi numerici,
espone "Digits" + "Zero-pad" nell'ispettore, scrive il byte in-place (stessa impronta),
e l'anteprima clampa/padda a digitCount per rispecchiare il firmware. Quindi rilegare la sorgente di un campo e
impostare il suo conteggio cifre funziona per qualsiasi campo (es. data→temperatura °F → Digits 3). Oracolo
in modalità normale invariato (0 regressioni, 3 piccoli miglioramenti); roundtrip byte-esatto su tutti i 103 quadranti.
(L'ipotesi precedente di "larghezza Digits"/rectW era sbagliata — la larghezza è solo layout, non il conteggio.)
struct e la coda di riferimenti alle risorse ✅La scena (§11.7) è un TLV nidificato pulito — [tag u8][len u16 LE][body]. Inventario completo dei tag come
osservato sul corpus:
✅ Questo inventario è completo per il corpus. Ricorrendo solo nei tag contenitore sopra, il
TLV della scena di tutti i 15 quadranti controllati cammina esattamente fino alla sua lunghezza radice dichiarata con zero tag
sconosciuti — quindi un parser che gestisce questa tabella gestisce l'intero formato, e un tag sconosciuto significa
una lettura disallineata, non un nuovo tipo di nodo. (Attenzione: un walker che ricorre in ogni nodo la cui lunghezza
capita di essere ≥ 3 scenderà nei body struct/0x5b e allucinerà una lunga coda di "tag" una tantum
— i body foglia non sono TLV.)
💡 Scorciatoia di authoring: l'auto-layout
0x48/0x68può essere saltato del tutto — ogni widget può essere posizionato conx,yassoluti direttamente al livello superiore dello schermo, che è ciò che fa il builder da zero (§11.7). Serve solo per leggere quadranti esistenti. Una larghezzametadi0x8000marca uno struct come figlio auto-layout di un frame (la posizione viene dal genitore, non dax,y).
Body 0x01 struct — un prefisso fisso di 18 byte seguito da una coda opzionale di riferimenti alle risorse:```
+0x00 x i16 [signed — can be negative, see §11.8]
+0x02 y i16
+0x04 meta[14] ────────────────────────────────────────────────────
meta[0..1] w u16 [0x8000 = auto-layout child of a frame]
meta[2..3] h u16
meta[4..6] unknown [placeholder-looking (1,0,0)/(4,0,0); see §11.14]
meta[7] accent-tint capability flag — 4 = tintable (§11.14)
meta[9] DATA SOURCE ID (§16)
meta[10] sub / variant
meta[11..13] max u24 LE [the metric's nominal full-scale value]
+0x12 ref tail [61 …] — absent on imageless rings (0x80/0x81, §11.15)
> ✅ Questo unifica gli "offset magici" dei §11.5/§11.8/§11.9. Quelle sezioni individuano i campi *relativi
> al byte `0x61` della tabella dei frame* — che è semplicemente `+0x12` di questa struct, quindi `−18`/`−16` = `x`/`y` e
> **`−5` = `meta[9]`, l'id della sorgente**. Stessi byte, un unico layout pulito. L'euristica di scansione in avanti
> dell'attributo `82` che era sistematicamente sfasata di uno non serve affatto: basta leggere `meta[9]` della struct.
> Il `max` di un campo numerico è anch'esso semplicemente `meta[11..13]` (ad es. i campi giorno-del-mese portano `max = 99`).
**Coda di rif** (`61`) — come un nodo punta alle sue bitmap:```
+0x00 0x61 [tail type]
+0x01 count u16 [1 = single image · 10/11 = digit atlas · N = pick-list / frame sheet]
+0x03 base u32 [ABSOLUTE FILE OFFSET of the first asset block]
+0x07 count × u16 = the BLOCK SIZE (8 + payload len) of each referenced asset, in order
⚠️ Quei u16 finali erano precedentemente documentati come "count×id(u16)" / id di glifi. Sono
dimensioni di blocco: base, più la somma progressiva di essi, percorre il pool di asset voce per voce (✅ verificato
esattamente per tutte le 10 voci di un atlante di cifre). Due conseguenze:
count−1 dimensioni sono portanti; il valore dell'ultima voce non viene mai seguito, quindi
i file in natura a volte contengono un valore obsoleto lì. Non trattare una discrepanza sulla voce
finale come un riferimento rotto.0x28 anteprima — la miniatura del negozio/catalogo è incorporata nel .bin stesso (27 nodi di anteprima
su 15 quadranti), come una pvStruct 0x08: un prefisso di 5 byte più la stessa coda di riferimento, con nessun x/y.
Utile per creare una UI a galleria senza distribuire PNG separati.
0x02 ✅Il fratello 0x02 di un widget lo rende condizionale. Senza di esso, il widget viene sempre disegnato. Grammatica:```
count u8 , count × ( id u8 , op u8 , val u24 LE signed ) [5 bytes per entry]
`id` è un id di origine dati (§16) — inclusi gli **id di slot sintetici** del §11.13. Operatori, con
conteggi di occorrenza misurati su 15 quadranti:
| op | significato | visti |
|---|---|---|
| `0x01` | disegna se `value == val` | 99 |
| `0x81` | come `0x01` (bit `0x80` impostato — appare su varianti mutuamente esclusive) | 48 |
| `0x02` | **nascondi** se `value == val` | 7 |
| `0x03` | disegna se `value == val`, dove `val` è un **marcatore di assenza dati** (es. HR `1000`) | 13 |
| `0x05` | disegna se `value >= val` | 58 |
| `0x06` | disegna se `value <= val` | 50 |
| `0x04` | ⚠️ **sconosciuto** — 15 occorrenze, nessuna semantica confermata | 15 |
Regola di combinazione (come implementata dal renderer di riferimento, maschera `op & 0x7f`): le voci di
uguaglianza vengono combinate in **OR**, poi le voci hide/`>=`/`<=` devono **tutte** essere soddisfatte.
Questo singolo meccanismo copre gran parte della variabilità runtime del formato e spiega strutture che
sembrano widget duplicati:
- **Layout 12h / 24h e metrico / imperiale** — due set di widget impilati nello stesso punto, ciascuno
legato a `id 0x73` (il flag delle unità) con `val` 0 o 1. Il quadrante 275 ha sei di queste coppie (12 nodi).
- **Segnaposto "Nessun dato"** — op `0x03` contro un sentinella, es. `id 0x5f, val 1000` (quadrante 275, due volte):
disegna l'arte del trattino lungo invece di una temperatura quando la metrica non è disponibile.
- **Evidenziazioni a bucket** — un intervallo accoppiato `0x05`/`0x06`, es. la catena di Metaball dove ogni anello si
illumina per la propria finestra di 5 minuti.
- **Alternate per slot di complicazioni** — legate agli id di slot sintetici del §11.13.
### 11.13 Slot di complicazioni configurabili — `0x85` + `0x5f` ✅ (sostituisce §11.8)
Il §11.8 concludeva che la metrica attiva di una complicazione configurabile è stato RAM del dispositivo e non poteva
essere recuperata dal file. **Ciò era sbagliato** — sia il menu delle metriche dello slot sia la sua selezione predefinita
sono nel `.bin`. Ogni nodo `0x85` porta un fratello `0x5f`:```
+0x00 slotIndex u8 [0-based position among sibling 0x85 nodes]
+0x01 count u8 [how many metrics this slot offers]
+0x02 activeIdx u8 [index into the list below = the DEFAULT SHOWN METRIC]
+0x03 count × u8 [the metric ids themselves (§16)] … NUL padding
Le varianti che vengono effettivamente disegnate sono i normali gruppi 0x68 altrove nell'albero, ciascuno condizionato da
una condizione 0x02 (§11.12) sull'id sintetico 0x79 + slotIndex — quindi le varianti dello slot 0 si legano a
0x79, quelle dello slot 1 a 0x7a, e così via. Per renderizzare uno slot: leggi activeIdx, poi disegna la variante la cui
condizione corrisponde a quell'indice.
Misurato su quadranti reali:
I due slot da 6 metriche del quadrante 275 coprono 12 dei suoi 26 nodi 0x02, esattamente come previsto:
01 79 81 0X 00 00 e 01 7a 81 0X 00 00 per X = 0..5 — sei varianti chiavate su 0x79 (slot 0)
e sei su 0x7a (slot 1). (I byte 0x79/0x7a che §11.8 chiamava "byte di istanza" sono questi
id di legame.) I restanti 14 non sono correlati: 12 su 0x73 (il flag 24h/unità metriche, val 0 o 1 — sei
coppie di widget che commutano tra layout 12h e 24h) e 2 su 0x5f con op 0x03 e val = 1000,
il segnaposto di nessun dato per la temperatura.
Ancora genuinamente stato del dispositivo: qualunque cosa l'utente scelga in seguito nell'app companion sovrascrive
activeIdx a runtime, quindi un'anteprima riproduce il default di file, non necessariamente ciò che mostra un dato
orologio. imgs[0] di un nodo 0x85 è un segnaposto "tocca per configurare" che il firmware disegna solo
nella propria modalità di modifica — saltalo quando anteprimi la normale visualizzazione dell'ora.
meta[7] == 4 ✅Alcuni quadranti consentono all'utente di scegliere un colore d'accento sul dispositivo, e il firmware lo sostituisce nelle
bitmap del widget al momento del rendering. L'interruttore è un singolo byte: meta[7] della struct (§11.11) —
cioè il byte +0x0B del corpo 0x01 — uguale a 4 contrassegna le risorse di quel widget come tintabili.
.bin distribuito deve mantenere i suoi pixel originali o perdi permanentemente la scelta dell'utente. Applica la
tinta solo nel percorso di anteprima/canvas.⚠️ Non "migliorare" questo in un'euristica del colore — quel percorso è un vicolo cieco dimostrato (documentato da fmc dopo averlo fatto nel modo più difficile). La teoria intuitiva è che i pixel contrassegnati siano incorporati in qualche colore segnaposto riconoscibile che il firmware sostituisce. Non può funzionare: l'anello tintabile del quadrante 348 Tumbler e le strisce di cifre ordinarie non tintabili dei quadranti 282 Radar Sweep / 291 Vertical incorporano l'esatto stesso RGB
(255,72,32)(verificato in modo esaustivo, ogni pixel); e i quadranti 305 Dots (lancetta delle ore) e 306 Large Number (cifre) sono tintabili pur essendo incorporati in bianco puro, quindi un test del colore li mancherebbe del tutto. Raffinamenti successivi (1 → 4 colori di riferimento, più una lista consentita di ruoli widget) hanno tutti fallito. Leggi il flag.Verificato incrociato contro il dispositivo reale / app companion su 7 quadranti, scelti per stressare entrambe le direzioni — 349 Theatre, 376 Digits time, 305 Dots, 306 Large Number, 304 Elaborate 2 offrono tutti l'impostazione dell'accento e hanno tutti widget
meta[7]==4; 316 Trailing (lancetta rossastra, nessuna impostazione), 312 Disc e 295 Vortex non ne offrono alcuna e hanno zero widget contrassegnati.
meta[4..6] si trova proprio accanto al flag e sembra poter codificare un colore su alcune struct
(un RGB dall'aspetto reale con coda f1=1,f2=255, rispetto al segnaposto (1,0,0)/(4,0,0) delle struct contrassegnate).
Non correla con la capacità di accento. ⚠️ Non risolto; ignoralo.
0x80/0x5a (procedurali) e 0x81/0x5b (ritaglio immagine) ✅Entrambe le varianti di anello abbinano una struct breve (x, y, meta con l'id sorgente — di solito senza
alcuna coda ref) a un fratello di specifica dell'arco. §11.5 documentava solo "0x5b: max u16 @+4", che è
la metà bassa di un max i32 e perde la geometria di scansione. Record completo:```
+0x00 min i32 LE [always 0 in the corpus]
+0x04 max i32 LE [100 in the corpus, except dial 332 = 60]
+0x08 start i16 LE [sweep start, units of 0.1° — SIGNED]
+0x0a end i16 LE [sweep end, units of 0.1° — SIGNED]
+0x0c width u16 LE [stroke width in px]
+0x0e radius u16 LE [0x5a ONLY — 0x81 takes its radius from the clipped image]
+0x0e / +0x10 trailer 01 00 kk ⚠️ unresolved (see below)
L'accoppiamento è rigido: `0x80` trasporta sempre esattamente `0x01` + un `0x5a` da **19 byte**, `0x81` sempre esattamente `0x01` + un `0x5b` da **17 byte** (✅ 26/26 anelli su 15 quadranti). ⚠️ Ma il **`0x81` ritagliato dall'immagine domina** — 25 di quei 26. La variante procedurale `0x80`/`0x5a` è apparsa **una volta** (quadrante 273), quindi il suo campo `radius` e il layout da 19 byte si basano su un singolo campione; trattala con sospetto finché non la rivedi.
`frac = clamp((valore − min) / (max − min), 0, 1)`, e l'arco riempito va da `start` verso `end`.
L'angolo zero è alle **3 in punto**, positivo in senso orario. Esempi misurati:
| quadrante | tag | min..max | start → end | larghezza | raggio |
|---|---|---|---|---|---|
| 273 Activity Mood | `0x5a` | 0..100 | **−102,8° → 102,8°** | 42 | 222 |
| 273 Activity Mood | `0x5b` | 0..100 | 270,0° → 90,0° | 80 | (immagine) |
| 276 Dichotomy | `0x5b` | 0..100 | 60,0° → −120,0° | 23 | (immagine) |
| 304 Elaborate 2 | `0x5b` | 0..100 | −2,0° → 358,0° | 24 / 80 | (immagine) |
| 366 Combo | `0x5b` | 0..100 | 0,0° → 270,0° | 18 | (immagine) |
| 368 Function | `0x5b` | 0..100 | 0,0° → 360,0° | 20 | (immagine) |
> ⚠️ **Questo corregge l'assunzione "in senso orario dalle 12 in punto"** nel §11.5. Quello è solo il caso
> speciale `start = 0, end = 3600` (una scansione completa, dove la convenzione è inosservabile). I quadranti reali usano
> **indicatori parziali** (il ventaglio ±102,8° del 273, l'anello a tre quarti da 270° del 366) e **scansioni negative**
> (60° → −120° del 276), quindi un renderer che esegue sempre una scansione completa del cerchio dall'alto li disegna in modo errato.
> 🟡 La convenzione esatta dell'angolo zero e la regola di direzione provengono dal renderer di fmc, incrociate
> con questi valori su disco — non verificate indipendentemente pixel-per-pixel sul dispositivo da questo repository.
> Nota che `frac = valore/max` e il ritaglio del settore `0x81` nel §11.5 **sono stati** validati (quadrante 322).
⚠️ **Non risolto: il trailer a 3 byte `01 00 kk`.** `kk` assume valori dall'aspetto plausibile (104, 152, 216,
232, 248) e l'ipotesi ovvia è un raggio — **testata e confutata**: il quadrante 366 usa `kk = 104`
sia per un anello 82×82 che per uno 166×166, e il quadrante 273 usa `kk = 232` sia per un anello 440×440 che per uno 284×284.
Non è un raggio, non è un diametro. Possibilmente opacità/stile. Reinstradalo così com'è.
🟡 **Insidia segnalata, non verificata qui:** nel renderer di fmc, un numero `0x60` il cui id sorgente è uguale all'id di **qualsiasi** anello sullo stesso schermo renderizza `round(frac × 100)` invece del valore grezzo — quindi un numero di frequenza cardiaca accanto a un anello di frequenza cardiaca mostra `36` invece di `71 bpm`. La loro soluzione documentata è di mettere l'anello e il numero su due id **alias** della stessa metrica (passi `0x19`/`0x26`/`0x49`, calorie `0x1c`/`0x1e`/`0x48`). Se questo sia un comportamento del firmware o specifico del loro renderer **non è stabilito** — vale un controllo dal vivo prima di progettare attorno a questo.
---
## 12. Dettagli sul trasferimento bulk e OTA
La tabella di trasferimento è nel §6. Punti aggiuntivi confermati:
- **AGPS/EPO** ✅: il primo blocco scritto inizia con l'header ASCII `000000010000…`. Il ciclo completo init → `[A05F ↔ 905F]×N` → finish è stato osservato sul filo (~892 blocchi).
- **OTA del firmware** (`9040`–`9042`, finish `9041`) 🔎: struttura mappata; payload INIT2 = byte di versione (es. `0b 00 00 39` = 11.0.0.57). **Non testato sul campo** (l'app disabilita l'aggiornamento FW qui). Le immagini del firmware sembrano essere **non firmate — l'integrità è solo CRC32** (nessuna firma asimmetrica osservata nella RE).
- ⚠️ Poiché OTA e `FACTORY_RESET (009A 0001)` condividono la sessione autenticata, una singola autenticazione BLE valida è sufficiente per cancellare o (in linea di principio) brickare l'orologio. Maneggia con cura.
---
## 13. Sensori
✅ Hardware esposto via BLE:
- **PPG ottico** — frequenza cardiaca (manuale/automatica/allenamento/riposo), SpO₂ e stress derivato da HRV.
- **Accelerometro a 3 assi** — passi, distanza, calorie, fasi del sonno, alzata del polso, cadenza.
- **GNSS/GPS** (assistito da AGPS) — traccia dell'allenamento (`WORKOUT_GPS`) e push della posizione (`GPS_PUSH`).
Non c'è **barometro/altimetro, bussola, giroscopio o sensore di temperatura cutanea/corporea**. Un termistore NTC interno (temperatura della scheda/batteria) esiste ma è leggibile **solo** tramite il canale AT (`AT GETNTCTEMP`, §14) — il flusso della cronologia della temperatura cutanea `0155` è vuoto su questo SKU.
**Dirottamenti dei widget dati** (non esiste una vera API di complicazioni/data-binding — vedi §11.5): i campi di testo esistenti dell'orologio possono essere riutilizzati per mostrare dati esterni a colpo d'occhio. Provato ✅: la stringa della **città meteo** (`WEATHER_SET_1`, es. `"BRA 2x1 ARG"` è apparsa sul widget) e i campi **traccia/artista** della musica; l'elenco dei **contatti** (20 × nome[32]+numero[25]) funziona come pannello dati scorrevole. Sono tutti push, non complicazioni persistenti.
---
## 14. Canale shell AT di fabbrica (`77d4ff01` / `77d4ff02`)
Un canale di comandi AT in testo semplice separato, indipendente dal protocollo incorniciato. ✅ testato dal vivo:
- **Lettura:** `AT GETSECRET` (segreto di accoppiamento a 16 byte), `GETVERSION`, `GETSN`, `GETNAME`, `GETPID`,
`GETBATLV` (mV grezzi, es. `3853mv`), `GETGSENSOR` (accelerazione grezza in g, `X=… Y=… Z=…`),
`GETNTCTEMP` (°C, NTC interno).
- **Scrittura / attuazione:** `AT SETMOTOR=1` (fai vibrare il motore), `SETHR/SETHRV/SETSPO2=…` (iniezione di test del sensore), `SETLCDSWITCH/SETGPSSWITCH/SETKEYSWITCH`.
Le risposte terminano con `,OK`. I comandi `SET*` generalmente si eseguono ma potrebbero non restituire `,OK` via BLE — conferma caso per caso.
---
## 15. Funzionalità limitate dal firmware / non disponibili (🔎 RE del firmware)
Alcune funzionalità sono presenti nel firmware ma disabilitate da SKU/regione e **non sono raggiungibili dal telefono/BLE** — richiedono una modifica del firmware, che è fuori ambito qui:
- **Voce ChatGPT** — gate = id funzionalità `ux2sys` `0x9e`, inizializzato da NVRAM/EFUSE/regione all'avvio; su questo SKU il flag di supporto `908b = 00`. Non influenzabile da telefono, account o BLE (confermato da esperimento + RE). L'app è solo un relay; l'audio va telefono → cloud Nothing.
- **Pressione sanguigna** — esiste un sottosistema completo nel firmware, disattivato da SKU/regione.
- **Alipay / pagamento NFC** — UI completa presente, solo SKU Cina.
- **Assenti in hardware/firmware:** ECG, SOS/emergenza, NFC generico.
---
## 16. Id getter per sorgenti dati / complicazioni
Non necessario per costruire un client BLE, ma **essenziale per creare o renderizzare un quadrante**: questo è il valore di `meta[9]` (§11.11) che lega un widget ai dati live, l'`id` di una condizione di visibilità (§11.12) e le voci del menu metriche di uno slot (§11.13). Il firmware lo risolve tramite una **tabella di dispatch getter a 142 voci** a `0x101f371c`, ogni voce chiama `ux2sys_get(type)` (🔎 RE del firmware).
### Ora / data — ✅ ben consolidato
| id | significato | id | significato |
|---|---|---|---|
| `0x01` | ora (12/24h secondo l'impostazione del dispositivo) | `0x0f`, `0x12` | secondo (fluido) |
| `0x04` | ora (24h) | `0x10`, `0x11` | decine / unità dei secondi |
| `0x07` | ora (24h forzato) | `0x71`, `0x72` | secondo (ticchettio / angolo lancetta) |
| `0x02`, `0x03` | decine / unità ora-12h | `0x13` | flag AM/PM (0 = AM, 1 = PM) |
| `0x05`, `0x06`, `0x08`, `0x09` | decine / unità dell'ora | `0x15`, `0x16` | mese |
| `0x0a`, `0x70` | angolo lancetta ore | `0x17` | giorno del mese |
| `0x0b` | minuto | `0x18` | giorno della settimana (0 = lunedì ⚠️) |
| `0x0c`, `0x0d` | decine / unità dei minuti | `0x0e`, `0x71` | angolo lancetta minuti |
Gli id decine/unità disegnano una **singola cifra** — un widget legato a uno di essi renderizza un glifo, non l'intero valore (quadrante 284 Square). Gli id angolo lancetta (§11.5) sono `0x0a`/`0x70` ore = `h·30° + m·0,5°`, `0x0e`/`0x71` minuti = `m·6° + s·0,1°`, `0x12`/`0x72` secondi = `s·6°` (✅ confermato disassemblando i getter, fallback RTC 10:10:30).
### Salute / sensori / meteo — ⚠️ contestato, leggi la colonna delle prove
| id | significato (migliore lettura attuale) | prove |
|---|---|---|
| `0x19` | **passi** | nel menu slot del 368 insieme a `0x1a`; corrisponde a `parse.ts` di questo repo |
| `0x1a` | **frequenza cardiaca** | nel menu slot del 368 insieme a `0x5f` e `0x19` |
| `0x1c` | calorie | menu slot, icona fiamma nell'app companion |
| `0x1e` | calorie (alias) | corpus |
| `0x22` / `0x23` | distanza km / mi (parte intera) | corpus |
| `0x74` / `0x75` | distanza km / mi (parte frazionaria) | corpus |
| `0x76` | distanza (forma slot) | menu slot, icona strada |
| `0x24` | **% batteria** | menu slot, icona fulmine |
| `0x30` | % batteria | corpus |
| `0x36` / `0x5f` | temperatura | `0x5f` = menu slot, icona nuvola-sole; 361 TempoG lega un numero semplice |
| `0x48` | ore in piedi (ore in piedi) | menu slot, icona figura in piedi |
| `0x8b` | AQI | menu slot |
| `0x73` | flag 24h / unità metriche | corpus |
| `0x25`–`0x27`, `0x49`, `0x6c`, `0x6f` | % obiettivo / alias slot di passi e calorie | corpus |
| `0x6a` | ⚠️ metrica slot non identificata | appare in 4 menu slot |
| `0x79 + slotIndex` | **sintetico** — non una metrica; l'id di selezione slot (§11.13) | ✅ §11.13 |
> ⚠️ **Tre tabelle in questo repo erano in disaccordo; questa è la riconciliazione.** Revisioni precedenti del §16
> e del §11.5 leggevano `0x19` come frequenza cardiaca, `0x1b` come batteria, `0x24` come temperatura e `0x36` come passi —
> e `wfweb/src/codec/mock.ts` codifica ancora quella lettura, mentre `wfweb/src/codec/parse.ts` ne codifica una
> diversa (`0x19` passi, `0x24` % obiettivo, `0x48` ore in piedi, `0x1a` meteo). La tabella sopra segue
> la lettura con prove migliori (etichette calibrate da fmc contro i menu icona degli slot widget dell'app companion, vedi §Fonti). **Il codice non è stato ancora modificato — `mock.ts` e `parse.ts` sono ancora incoerenti tra loro e con questa tabella.** Tratta le etichette degli id salute come ⚠️ finché qualcuno non lega un campo a ciascun id e legge l'orologio.
>
> La prova singola più forte è **il menu slot del quadrante 368 Function** (§11.13), che offre
> `0x5f 0x1c 0x19 0x48 0x24 0x76 0x1a 0x8b` come **otto metriche distinte selezionabili dall'utente** in un unico menu.
> Qualunque siano le etichette, nessuna coppia di quegli otto può essere la stessa metrica — il che esclude
> `0x19` = `0x1a` = frequenza cardiaca e `0x24` = `0x5f` = temperatura simultaneamente.
Le complicazioni ad anello/arco con un **foglio di frame** indicizzano un frame pre-renderizzato (es. 50 % = frame 50 di 100)
incorporato nel `.bin` che invii (§11.5), quindi non serve un pacchetto RES esterno; gli anelli senza immagine vengono disegnati
dalla specifica dell'arco invece (§11.15).
---
### Quick-cards (tile home) — `QUICK_CARD (906D)` ✅
Le tile home dell'orologio. Il telefono sceglie solo **quali** tile mostrare e in **che ordine** — le tile sono
renderizzate dal firmware (nessun canale di contenuto). Primo byte del payload = sottocomando: `00` = GET, `01` =
SET; **entrambi usano `0x906D`** (`0x906C` è elencato ma non usato — interrogarlo va in timeout). Risposta = `A06D`.
> 🛑 Inviare un `assemblyId` inventato **cancella gli schermi dell'orologio** (accetta l'elenco, non può abbinare
> gli id, non mostra nulla). Invia solo id che **rileggi** tramite GET; recupera tramite l'app ufficiale o un
> reset di fabbrica.
**Risposta GET** ✅: `status(1) ‖ 00 ‖ N(1) ‖ N × gruppo`, gruppo = `tag=01 ‖ K(1) ‖ K×(assemblyId, sportId)`.
Frame reale: `01 00 04 01 02 5d00 6100 01 03 1900 2e00 2300 01 03 5c00 0400 5a02 01 03 4800 5100 5300`
= 4 schermi / 11 card (`5a02` = card Sport, sportId 2).
**Slot:** ogni schermo ha **4 slot**. Il tipo di una card imposta la sua dimensione — `circolare`/`quadrata` = 1 slot,
`rettangolo` = 2 slot. La validazione è pura aritmetica degli slot (Σ ≤ 4 per schermo); nessuna card mutuamente esclusiva. `sportId` è `0` tranne sulle card Sport (87–91). Gli id `64` e `95` non esistono.
**Catalogo `assemblyId`** (ogni tipo logico = un intervallo contiguo di 6 varianti di stile `_0`..`_5`;
`0` = slot vuoto):
| dec | card | dec | card |
|----|----|----|----|
| 0 | slot vuoto | 49–53,97–98 | Meteo |
| 1–6 | Passi | 54–58 | Timer |
| 7–12 | Calorie | 59–62 | Respirazione |
| 13–18 | In piedi | 63,65–67 | Cronometro |
| 19–24 | Attività moderata | 68–71 | Batteria |
| 25–30 | Frequenza cardiaca | 72–76 | Recenti |
| 31–36 | SpO₂ | 77–81 | Contatti |
| 37–42 | Stress | 82–86 | Quadrante / telefono |
| 43–48 | Sonno | 87–91 | Sport (`sportId` ≠ 0) |
| 92 | Musica | 93/94/96 | Registro attività / PAI / Ciclo |
---
## Appendice A. Catalogo quadranti stock (id → nome)
Il §11 si riferisce ai quadranti con id numerico in tutto il documento (275 SlopeTime, 322 Glare 2, 357 Silhouette, …). Gli id
`273`–`376` sono i volti del negozio/stock; l'id è ciò che `DIAL_COMMAND (9055/a055)` riporta come attivo e
ciò che `9075` accetta come `old_id` (§11.4). 100 dei 103 id noti sono nominati sotto; i restanti sono
stub ROM (§11.4). Nomi e raggruppamenti come mostrati dall'app companion ufficiale.
- **Predefinito** (6) — `273` Activity Mood · `274` Sun Circle · `275` SlopeTime · `276` Dichotomy · `277` Prismatic Time · `280` Multifunction
- **Analogico** (34) — `286` Sundial · `287` Simple Dial · `292` City · `294` Sudoku · `305` Dots · `306` Large Number · `309` Gradient · `310` Glare · `311` Bold · `313` Classical · `314` Fragment · `315` Infinite · `316` Trailing · `322` Glare 2 · `326` Chrono Master · `327` Digit Max · `328` Coherent · `329` Wheel · `330` Zenith · `331` Intersection · `335` Time Phase · `336` Energetic · `338` Chronos · `341` Dual View · `346` Time Windmill · `347` Large Panel · `349` Theatre · `352` Elegant Sweep · `360` Explorer · `364` SportPulse · `370` Hemisphere · `371` ActiveTrio · `372` Time Wheel · `373` Traditional Pointer
- **Digitale** (41) — `281` Metaball · `282` Radar Sweep · `283` Radio · `285` Widgets · `288` Type · `289` Rotate · `290` Gradual · `291` Vertical · `293` Stairs · `296` Ladder · `297` Ray · `298` Eclectic · `299` Echo · `300` Mono Dial · `301` Orbit · `302` Calendar · `303` Space · `307` Sprung · `308` Sundial 2 · `319` One Line · `320` Orienteer · `321` Revolution · `323` Dash · `324` Finesse · `325` Metric · `333` Circularity · `334` Globe of Time · `337` Time Finder · `339` Suprematism · `340` Sport Mode · `345` Time Dot · `350` Timeline · `351` Cyclopes · `353` Dual phase · `357` Silhouette · `359` Ring data · `361` TempoG · `362` Steady · `365` Elegance · `369` Solar System · `376` Digits time
- **Multifunzione** (10) — `304` Elaborate 2 · `344` InfoMeter · `348` Tumbler · `354` Dual · `363` Vintage · `366` Combo · `367` Complex Figure · `368` Function · `374` Cirquary · `375` InfoHub
- **Creativo** (8) — `284` Square · `312` Disc · `317` Disc 2 · `318` Dominos · `332` Flux · `342` Perfect Match · `343` Progress Day · `358` Asteroid
- **Diwali** (1) — `295` Vortex
---
## Fonti
I layout dei byte sopra sono stati ricostruiti dal firmware (1.0.0.73), dall'APK ufficiale (3.5.7) e da
catture live decriptate contro un dispositivo reale. L'implementazione di riferimento per questo progetto vive in
`core-rust/src/{commands,frame,crypto,health,session}.rs` (Rust), l'editor TypeScript `wfweb/` e
gli strumenti Python `cmftool/` (`pair.py`, `session.py`, `wf_codec.py`, `upload_custom.py`, …).
**Lavoro indipendente incorporato qui.** [freethinkel/fmc](https://github.com/freethinkel/fmc) — un
editor di watchface SvelteKit + marketplace per lo stesso orologio — ha indipendentemente fatto reverse engineering del
formato `.bin` da un corpus di ~100 volti e ha raggiunto diversi risultati che questo documento mancava o aveva
sbagliato. I loro `docs/cmf-protocol.md`, `src/lib/modules/editor/lib/{wf,render}.ts` e
`src/lib/modules/device/lib/ble.ts` valgono la lettura diretta. Risultati adottati, ciascuno ri-verificato
contro i byte dei quadranti prima di essere scritto qui:
| Risultato | Dove | Stato qui |
|---|---|---|
| Entrambe le parole header sono CRC32 (variante non standard) | §11.4 | ✅ ri-verificato 9/9 quadranti; **corregge** "nessun checksum bloccante" |
| Condizioni di visibilità, tag `0x02` | §11.12 | ✅ ri-verificato; era non documentato |
| Elenco metriche slot + `activeIdx` predefinito, `0x79 + slotIndex` | §11.13 | ✅ ri-verificato su 275/368/273/304; **sostituisce** §11.8 |
| Flag capacità tinta accento `meta[7] == 4` | §11.14 | ✅ prevalenza ri-verificata; era non documentato |
| Specifica arco completa (angoli di scansione, larghezza, raggio) | §11.15 | ✅ ri-verificato; **corregge** "`max` u16 `@+4`" |
| I `u16` della coda ref sono dimensioni dei blocchi, non id glifo | §11.11 | ✅ ri-verificato esattamente su un atlante a 10 glifi |
| Footer a 36 byte; variante magic byte `0x02`; `0x86` = 64 B; anteprima incorporata `0x28` | §11.4, §11.11 | ✅ ri-verificato |
| Etichette id salute/meteo calibrate contro il menu slot dell'app companion | §16 | ⚠️ adottato come migliore lettura; conflitti segnalati |
| L'UUID del servizio shell è `77d4e67c-…`, e l'ambito `optionalServices` di Web Bluetooth | §1 | ⚠️ report a unità singola, non ri-verificato qui |
| Numero che condivide l'id con l'anello renderizza una percentuale | §11.15 | 🟡 segnalato, **non** verificato qui |
| Catalogo id → nome quadranti stock | Appendice A | ✅ adottato così com'è |
| 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 live) | FFFF 9078 / FFFF A078 |
| FEMALE_CYCLE_SET / _RET | FFFF 9071 / FFFF A071 |
| SLEEP_CONFIG_SET / _RET (minuto 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 (elenco/riordino/selezione) | 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 |
|---|
| Quadrante (foto) | 8052/0052 | 9063/A063 | A064/9064 | A065/9065 |
| Quadrante (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 epoch) |
| 4 | 4 | passi |
| 8 | 4 | distanza (m) |
| 12 | 4 | calorie |
| 16 | 16 | riservato (osservato 0) |
| Offset | Dimensione | Campo |
|---|
| 0 | 4 | inizio_sessione (epoch, UTC) |
| 4 | 4 | risveglio (epoch, 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/score sessione? 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,
riempita con 0xff — sbagliato). 40 byte per sveglia, big-endian:
secondiDelGiorno(i32) ‖ indice(u8) ‖ abilitata(u8) ‖ maschera-ripetizione(u8) ‖ flag(u8) ‖ etichetta[32] UTF-8.
L'etichetta è all'offset 8 e viene mostrata sull'orologio. ripetizione = maschera dei giorni della settimana (0 = una tantum);
flag è ⚠️ [incerto] (marcatore una tantum?). Esempio (13:30, idx 2): 0000bdd8 02 01 15 00 "Alarm…".005E 0001) ✅ — l'app ufficiale e l'implementazione di riferimento usano il
DailyTargetBean v1 da 10 byte, big-endian: passi(u32 BE) ‖ distanza_m(u32 BE) ‖ calorie_kcal(u16 BE). (Questa è la forma di Gadgetbridge; i precedenti rapporti che l'orologio lo "ignorava"
erano un bug di decrittazione di sessione stantia, non un problema di payload.) 🔎 La RE del firmware mostra anche una variante
estesa più lunga da 29 byte (aggiunge sleep_min/exercise_min/stand_h + 6 flag di abilitazione, tutti u32
BE dopo un prefisso flag(u16 LE), con intervalli imposti: passi 2000–30000, dist 1000–99000, cal
100–5000, sonno 360–720, esercizio 30–90, in piedi 6–16) — non il percorso predefinito dell'app; preferisci la
forma da 10 byte a meno che tu non abbia bisogno dei target extra.0060/0061 0001) ✅: 11 byte:
abilitato(1) ‖ soglia_min(u16 LE) ‖ dndStart(u32 LE) ‖ dndEnd(u32 LE). Nota che la "finestra attiva
08:00–22:00" mostrata nell'interfaccia è un default fisso del firmware e non è trasportata nel payload.00DC 0001) ✅: count(1) = 36 slot ‖ activityTypeCode[36] (codici attivi poi padding 00).
Seleziona quali sport appaiono nel menu allenamento dell'orologio.009B 0001) ✅: byte kind — 01 = FC 24/7, 02 = SpO₂,
04 = stress (misurato ogni 30 min).FFFF 9059) ✅: disabilitato = 00; abilitato =
01 ‖ fcBassa ‖ fcAlta ‖ fcAltaSport ‖ spo2Bassa ‖ 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 — count(1) ‖ total(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]…
(7 risposte predefinite catturate e decrittate).FFFF 906F) ✅: invia ID città numerici, non nomi (01 ‖ count ‖ cityId(2 BE)…);
l'orologio mappa gli id da una tabella interna. Config DST FFFF 9083 =
count ‖ [id(u16 LE) ‖ dst(u16 LE) ‖ inizio(u32 LE) ‖ fine(u32 LE)]….FFFF 905C, 131 B) ✅: stato(1: 0=nessuno/1=pausa/2=riproduzione) ‖ volume(1) ‖ volumeMax(1) ‖ traccia(64) ‖ artista(64). L'orologio invia anche MUSIC_BUTTON (A05D) indietro.FFFF 906B, 199 B) ✅ — usa questo: 7×9 byte giorni + 24×2 byte ore +
città(32) + 7×8 byte alba/tramonto (LE). Le temperature sono codificate come (temp_c + 100) & 0xFF.
⚠️ Lo stesso payload inviato su WEATHER_SET_2 (0066 0001) non aggiorna il widget meteo su
Pro 2 — usa sempre 906B. (La stringa città è anche un vettore provato di data-hijack — vedi §13.)005D 0001) ✅: payload 0x01 → l'orologio suona/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 su 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, lancette, icone) |
| 13 (0x0d) | 0.5 | maschera alpha a 4 bit; il firmware colora a runtime | atlante glifi cifre |
| 24 (0x18) | 4 | RGBA8888 | layer a colori pieni (incl. il sempre-attivo aodImage) |
| 1 | — | JPEG/JFIF (ff d8 ff), estrai con qualsiasi decoder | rari frame di animazione |
0a0x61NotEnvelope0aChildOverflow:61 0a 00−18/−160a.bin0x68
impilati alle stesse (x,y), ciascuno disegnato solo quando una condizione di visibilità corrisponde — questo
era giusto. Ma la lista di metriche per slot (il 0x1c/0x6a/0x48/0x24/0x19/0x76 di 275) è esattamente la
lista di metriche, non "id di opzioni/stili"; l'indice attivo predefinito è un byte nel file; e il
byte 0x79/0x7a non è un "byte di istanza" ma l'id su cui sono chiavate le alternative
(0x79 + slotIndex). Un'anteprima statica può riprodurre il default del file. Vedi §11.13.(446,0), visto su 275/302/325/365/375)
sono slot che il firmware non disegna nella vista predefinita — il loro valore non può nemmeno entrare prima del
bordo della tela. Trattali come nascosti nell'anteprima.0x220x22aod0x22@69,2090x60 img_number standalone (cnt=10) — sorgente a −5, fuori di uno in avanti. ✅ Stesso fuori di uno
della §11.8 ma per numeri non-orologio: la data di "Gradient" stava a (203,80) in alto al centro con sorgente
0x17, ma la scansione in avanti 82 catturava il getter dell'angolo del puntatore vicino (0x0a) e
la posizione del puntatore → il numero renderizzava al posto del puntatore con una sorgente fasulla. Fix: per un
61 0a 00 img_number in un wrapper 0x60, fidati di −5/−18/−16 quando la sorgente in avanti è
impossibile per un numero (sorgente-0 o un getter di angolo del puntatore 0x0a/0e/12/70/71/72) e la
posizione −18/−16 è valida e non-zero (il guard non-zero salta le cifre figlie di gruppo con relX=0).0x17 = data (giorno del mese), 0x24 = temperatura — distinti. Il quadrante 340 usa entrambi (0x17
"Jun 09" e una 0x24 temp separata), quindi 0x17 è data, non temp. Un quadrante il cui orologio mostra una
temperatura in uno slot 0x17 è una complicazione configurata dall'utente (stato del dispositivo), non il default del file.| tag | ruolo | contenitore? | body |
|---|
0x20 | radice della scena (wrapper del body, non un drawable) | ✅ | figli |
0x21 | schermata normale | ✅ | figli |
0x22 | schermata AOD (§11.9) | ✅ | figli |
0x28 | miniatura di anteprima catalogo incorporata | ✅ | un figlio 0x08 |
0x68 | gruppo / contenitore auto-layout | ✅ | frame 0x48 + figli |
0x30 | immagine statica, o pick-by-value da N immagini | ✅ | 0x01 (+0x02) |
0x60 | lettura numerica live (striscia di cifre) | ✅ | 0x01 + 0x40 (+0x02) |
0x70 | lancetta rotante | ✅ | 0x01 + pivot 0x05 |
0x80 | anello di progresso, procedurale | ✅ | 0x01 + 0x5a (§11.15) |
0x81 | anello di progresso, clippato da immagine | ✅ | 0x01 + 0x5b (§11.15) |
0x85 | slot di complicazione assegnabile dall'utente | ✅ | 0x01 + 0x5f (§11.13) |
0x01 | struct — geometria + attributi (sotto) | — | x,y,meta[14] + coda ref |
0x02 | condizione di visibilità (§11.12) | — | lista di condizioni |
0x05 | pivot — flag u8, pivotX u16, pivotY u16 | — | 5 B |
0x08 | pvStruct — prefix[5] + coda ref, senza x/y (solo anteprima) | — | — |
0x40 | conteggio cifre / flag zero-pad (§11.10) | — | 1 B |
0x48 | frame — x,y,w,h,gap,align riga/colonna auto-layout | — | — |
0x5a / 0x5b | specifica arco per 0x80 / 0x81 (§11.15) | — | 19 B / 17 B |
0x5f | lista di metriche slot per 0x85 (§11.13) | — | — |
0x86 | nodo nome visualizzato, sempre esattamente 64 byte, terminato NUL, non disegnato | — | 64 B |
| quadrante | slot | conteggio | activeIdx | id metriche | → attivo |
|---|
| 275 SlopeTime | 0 | 6 | 0 | 1c 6a 48 24 19 76 | 0x1c calorie |
| 275 SlopeTime | 1 | 6 | 4 | 1c 6a 48 24 19 76 | 0x19 passi |
| 368 Function | 0 | 8 | 0 | 5f 1c 19 48 24 76 1a 8b | 0x5f temperatura |
| 368 Function | 1 | 8 | 6 | 5f 1c 19 48 24 76 1a 8b | 0x1a frequenza cardiaca |
| 273 Activity Mood | 0 | 4 | 0 | 1c 24 48 6a | 0x1c calorie |
| 304 Elaborate 2 | 0/1 | 4 | 0 | 1c 48 6a 24 / 24 1c 6a 48 | 0x1c / 0x24 |