Skip to content
KitploitKITPLOIT
StrumentiBlog
Invia
StrumentiBlog
Invia

Strumenti di Hacking, PenTest e Cybersecurity per il tuo Arsenale di Sicurezza!

Kitploit è una directory di strumenti di hacking, cybersecurity e pentesting. Scopri gli ultimi aggiornamenti dei progetti per trovare vulnerabilità, analizzare sistemi, automatizzare i test e rafforzare la tua sicurezza.

··Feed·Contatto·Privacy·© 2026 Kitploit

Directory degli strumenti

Categorie

Vedi tutte le categorie
Loading categories
CMF-Watch-Pro-2-BLE-Protocol — 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. | Kitploit
Strumenti/GitHubGitHub/joshuapassos/cmf-watch-pro-2-ble-protocol
Sicurezza Sistemi EmbeddedSicurezza BluetoothSicurezza IoTReverse EngineeringSicurezza WirelessCrittografiaSicurezza MobileSicurezza Hardware e IoTAnalisi del Firmware

Più Popolari

Vedi tutti →

Scopri gli strumenti più utilizzati dalla nostra community.

Esplora tutti gli strumenti

Sfoglia la nostra collezione di strumenti

Vedi tutti gli strumenti →
Condividi
GitHubjoshuapassos/cmf-watch-pro-2-ble-protocol

CMF-Watch-Pro-2-BLE-Protocol

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.

Vedi RepositorySito web
324 giorni faNon ancora revisionato

CMF Watch Pro 2 — Protocollo BLE (reverse-engineered)

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

Indicatori di certezza

Ogni affermazione non ovvia di seguito è etichettata con il metodo con cui è stata stabilita:

  • ✅ validato sul dispositivo — osservato in una cattura live decifrata o testato su un orologio reale.
  • 🔎 da firmware / RE dell'APK — estratto decompilando il firmware (1.0.0.73) o l'APK ufficiale (3.5.7); coerente con il codice ma non testato a runtime.
  • ⚠️ [incerto] — dedotto, non confermato; potrebbe essere errato.

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.


1. Layout GATT

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.


2. Formato frame (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 ____________________________/

root@kitploit:~
- `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.


4. Handshake di autenticazione/associazione

Due percorsi di ingresso condividono la stessa coda nonce/confirm.

4.1 Associazione per la prima volta (si possiede il segreto del dispositivo)```

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

root@kitploit:~
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.

4.3 Inizializzazione post-autenticazione (fase 2)

⚠️→✅ TIME è obbligatorio prima delle interrogazioni dati. Dopo Initialized, l'orologio non risponderà a BATTERY, SERIAL_NUMBER_GET o all'handshake ACTIVITY_FETCH_* fino a quando non viene inviato un TIME (FFFF 8004) nella sessione — senza di esso, arriva solo un FIRMWARE_VERSION_RET non richiesto e tutto il resto va in timeout. ✅ confermato dal vivo (Pixel 8a): inviando i tre GET senza TIME → risponde solo il firmware; inviando TIME per 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).

4.4 Schema GET → SET echo (✅)

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.


5. Testo chiaro vs crittografato

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.


6. Riferimento opcode (cmd1, cmd2)

GET/SET/REQUEST = telefono→orologio; RET/REPLY/ACK/RESPONSE/DATA = orologio→telefono.

Sessione / dispositivo

Auth

Notifiche / chiamata / trova

Musica

Nomecmd1,cmd2
MUSIC_INFO_SET / _ACKFFFF 905C / FFFF A05C
MUSIC_BUTTONFFFF A05D

Sveglie / contatti / promemoria

Config

Meteo

Nomecmd1,cmd2
WEATHER_SET_1 (quello che funziona)FFFF 906B
WEATHER_SET_2 (ignorato su Pro 2 — vedi §9)0066 0001

Watch faces / quadranti

Salute / sincronizzazione

Opcode solo JS (FFFF 8051, FFFF 0051, FFFF 90A2, FFFF 90C5, FFFF A056, FFFF 908A/908B stato/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].

Trasferimento dati bulk (canale dati)

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.


7. Ora e fuso orario

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.


8. Sincronizzazione salute

  1. Il telefono invia ACTIVITY_FETCH_1; l'orologio risponde ACTIVITY_FETCH_ACK_1 (primo byte 01 ⇒ pronto).
  2. Il telefono invia ACTIVITY_FETCH_2; l'orologio spinge quindi un burst di frame dati: ACTIVITY_DATA, HEART_RATE_*, SPO2, STRESS, SLEEP_DATA, WORKOUT_SUMMARY[_V3].
  3. Ciascuno viene analizzato in campioni / sessioni al minuto e aggregato per giorno locale.

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

8.1 Record attività — 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.)

8.2 Campioni HR / SpO₂ / Stress ✅

  • HR manuale/automatico, HR allenamento, SpO₂, stress = 8 byte ciascuno: timestamp(i32 LE) ‖ valore(i32 LE) (valore = bpm / % SpO₂ / indice stress).
  • HR a riposo (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.

8.3 Sonno — 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).

8.4 Riepilogo allenamento — 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.


9. Payload di comandi selezionati

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.

  • APP_NOTIFICATION (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.
  • BATTERY (005C 0001) ✅: risposta = level(1) ‖ charging(1) (es. 3b 00 = 59 %, non in carica).
  • SERIAL_NUMBER_RET (00DE 0001) ✅: len(1) ‖ ASCII (es. 10 + "CI04102520008192").
  • USER_INFO (0095 0001) ✅: altezza_cm(1) ‖ peso_kg(1) ‖ età(1) ‖ sesso(1: 1=M) (es. = 172 cm / 73 kg / 31 / maschio).

10. Note di implementazione e stranezze

  • Nessun orologio di sistema nei codec: i codificatori prendono 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.
  • Il conteggio CRC in chiaro (§2) è facile da sbagliare — i frame in chiaro pubblicizzano ma omettono il CRC.
  • Endianness: intestazione + opcode BE; interi payload LE; eccezioni — GOALS_SET e GPS_PUSH sono big-endian, e offset/lunghezza del trasferimento bulk sono big-endian.
  • MTU: le dimensioni dei chunk sono calcolate in modo che i chunk crittografati siano allineati a blocchi AES di 16 byte.
  • authkey è persistibile (salvarlo dopo il primo abbinamento); sessionKey è per connessione e derivata dal nonce dell'orologio ad ogni riconnessione.

11. Watch faces / quadranti — creazione

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 tramite 9075 (la prova offline strutturale copre già ciò che causava il rifiuto 0a). 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.

11.1 Gestione quadranti — DIAL_COMMAND (9055 / a055) ✅

  • tipo 0 = interrogare l'elenco. Risposta 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.
  • tipo 1 = riordinare / selezionare attivo: reinviare l'intero elenco con il quadrante target all'indice 0 (è così che l'app ufficiale cambia quadrante; non esiste un opcode dedicato "imposta attivo").
  • Eliminare un quadrante = reinviare l'elenco senza il suo id.
  • CHANGE_DIAL (009F 0001) è inerte su fw 1.0.0.73 (restituisce una costante, non cambia quadrante) — non usarlo.

11.2 Flusso di trasferimento ✅```

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)

root@kitploit:~
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

root@kitploit:~
`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)

root@kitploit:~
`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.

11.8 Render-fidelity refinements (2026-07-02, dial 275 "SlopeTime")

Cross-referenced the wfweb render against the official store thumbnails (pixel oracle over all 103 dials) and closed four gaps:

  • Drawable/pointer X/Y are 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.
  • Digital clock digits can be top-level 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).

11.9 AOD-container skip + standalone img_number source (2026-07-03, dial "Gradient")

  • Separate the 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.

11.10 img_number DIGIT COUNT — the 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):

  • low nibble XX & 0x0F = number of digit slots (0 ⇒ firmware default 7).
  • bit 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.)


12. Bulk transfer & OTA details

The transfer table is in §6. Additional confirmed points:

  • AGPS/EPO ✅: the first written chunk begins with the ASCII header 000000010000…. The full init → [A05F ↔ 905F]×N → finish loop was observed on the wire (~892 chunks).
  • Firmware OTA (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).
  • ⚠️ Because OTA and 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.

13. Sensors

✅ Hardware exposed via BLE:

  • Optical PPG — heart rate (manual/auto/workout/resting), SpO₂, and HRV-derived stress.
  • 3-axis accelerometer — steps, distance, calories, sleep staging, wrist-raise, cadence.
  • GNSS/GPS (AGPS-assisted) — workout track (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.


14. AT factory / shell channel (77d4ff01 / 77d4ff02)

A separate plain-text AT command channel, independent of the framed protocol. ✅ tested live:

  • Read: 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).
  • Write / actuate: 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.


15. Firmware-gated / unavailable features (🔎 firmware RE)

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:

  • ChatGPT voice — gate = 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.
  • Blood pressure — a complete subsystem exists in firmware, switched off by SKU/region.
  • Alipay / NFC payment — full UI present, China-SKU only.
  • Absent in hardware/firmware: ECG, SOS/emergency, generic NFC.

16. Complication getter table (🔎 firmware-internal reference)

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-cards (home tiles) — 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 assemblyId wipes 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, …).

Scarica lo strumento
ScopoServizioCaratteristicaProprietà
Scrittura comandi0000fff0-0000-1000-8000-00805f9b34fb0000fff2-…Write
Notifica comandi0000fff0-…0000fff1-…Notify
Scrittura shell (AT)—77d4ff01-2fe2-2334-0d35-9ccd078f529cWrite
Notifica shell (AT)—77d4ff02-…Notify
Scrittura dati bulk—02f00000-0000-0000-0000-00000000ffe1Write
Notifica dati bulk—02f00000-…ffe2Notify
Nomecmd1,cmd2
TIMEFFFF 8004
FIRMWARE_VERSION_GET / _RETFFFF 8006 / FFFF 0006
SERIAL_NUMBER_GET / _RET00DE 0002 / 00DE 0001
BATTERY005C 0001
TRIGGER_SYNC005C 0002
USER_INFO_SET / _RET 🔎✅0095 0001 / 0095 0003
FACTORY_RESET009A 0001
DEVICE_REBOOT 🔎FFFF 9080
RESOLUTION_GET 🔎 (→ 466×360)FFFF 907F
GPS_PUSH / _RETFFFF 906A / FFFF A06A
UNBIND_SET / _RETFFFF 907A / FFFF A07A
Nomecmd1,cmd2
AUTH_PHONE_NAMEFFFF 8049
AUTH_WATCH_MACFFFF 0049
AUTH_PAIR_REQUEST / _REPLYFFFF 8047 / FFFF 0048
AUTH_NONCE_REQUEST / _REPLYFFFF 804B / FFFF 004C
AUTHENTICATED_CONFIRM_REQUEST / _REPLYFFFF 804D / FFFF 0004
AUTH_FAILEDFFFF A061
Nomecmd1,cmd2
APP_NOTIFICATION0065 0001
INCOMING_CALL ⚠️0064 0001
CALL_REMINDER_REQUEST / _RESPONSEFFFF 9066 / FFFF A066
FIND_PHONE005B 0001
FIND_WATCH005D 0001
FIND_WATCH_TOGGLEFFFF 9069
SMS_MESSAGE_PUSH / _RETFFFF 906E / FFFF A06E
QUICK_REPLY_SET / _RETFFFF 9073 / FFFF A073
Nomecmd1,cmd2
ALARMS_SET / _GET0063 0001 / 0063 0002
CONTACTS_SET / _GET00D5 0001 / 00D5 0002
STANDING_REMINDER_SET / _GET0060 0001 / 0060 0002
WATER_REMINDER_SET / _GET0061 0001 / 0061 0002
TASK_REMINDER_SET / _RET ⚠️FFFF 9072 / FFFF A072
Nomecmd1,cmd2
GOALS_SET / _ACK005E 0001 / 005E 0003
UNIT_LENGTH / _ACKFFFF 9067 / FFFF A067
UNIT_TEMPERATURE / _ACKFFFF 9068 / FFFF A068
TIME_FORMAT / _ACK005F 0001 / 005F 0003
WAKE_ON_WRIST_RAISE / _GET / _ACK0062 0001 / 0062 0002 / 0062 0003
LANGUAGE_SET / _RETFFFF 9058 / FFFF A06B
HEART_MONITORING_ENABLED_SET / _GET009B 0001 / 009B 0002
HEART_MONITORING_ALERTSFFFF 9059
DO_NOT_DISTURB / _GET0099 0001 / 0099 0002
SPORTS_SET / _GET00DC 0001 / 00DC 0002
SPORT_LINKAGE_SET / _RETFFFF 9076 / FFFF A076
SPORT_DATA_SYNC 🔎 (FC/cal/passi in tempo reale)FFFF 9078 / FFFF A078
FEMALE_CYCLE_SET / _RETFFFF 9071 / FFFF A071
SLEEP_CONFIG_SET / _RET (minuti target)FFFF 9074 / FFFF A074
WORLD_CLOCK_GETFFFF 906F
WORLD_CLOCK_DST_SET / _RETFFFF 9083 / FFFF A083
VITALITY_GET / _RETFFFF 9079 / FFFF A079
VITALITY_SW_SET / _RETFFFF 9070 / FFFF A070
Nomecmd1,cmd2
DIAL_COMMAND_SET / _RET (elenca/riordina/seleziona)FFFF 9055 / FFFF A055
DIAL_CONFIG_SET / _RETFFFF 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
Nomecmd1,cmd2
ACTIVITY_FETCH_1 / _2FFFF 8005 / FFFF 9057
ACTIVITY_FETCH_ACK_1 / _2FFFF 0005 / FFFF A057
ACTIVITY_DATA0056 0001
SLEEP_DATA / _GET0058 0001 / 0058 0002
SPO20055 0001
STRESS009D 0001
HEART_RATE_MANUAL_AUTO0053 0001
HEART_RATE_RESTING00DA 0001
HEART_RATE_WORKOUT00E0 0001
SKIN_TEMP_HISTORY 🔎 (vuoto su questo SKU)0155 0001 / 0155 0002
WORKOUT_SUMMARY / _V30057 0001 / 0160 0001
WORKOUT_GPSFFFF A05A
DominioINIT1 req/replyINIT2 req/replyCHUNK req/writeFINISH ack1/ack2
Watchface (foto)8052/00529063/A063A064/9064A065/9065
Watchface (strutturato/switch)8052/00529075/A075A064/9064A065/9065
Firmware9052/A0529040/A040A042/9042A041/9041
AGPS/EPO905E/A05E—A05F/905FA060/9060
OffsetDimensioneCampo
04timestamp (s epoca)
44passi
84distanza (m)
124calorie
1616riservato (osservato 0)
OffsetDimensioneCampo
04inizio_sessione (epoca, UTC)
44risveglio (epoca, UTC)
82totale_profondo_s
102totale_leggero_s
122totale_rem_s
142totale_veglia_s
162⚠️ [incerto] (id sessione/punteggio? i valori osservati non corrispondono alle somme dei record)
ac 49 1f 01
  • CONTACTS_SET (00D5 0001) ✅: N × 57 byte = nome(32) ‖ telefono(25). L'interfaccia dell'orologio mostra fino a 20.
  • ALARMS_SET (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…".
  • GOALS_SET (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.
  • STANDING_REMINDER / WATER_REMINDER (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.
  • SPORTS_SET (00DC 0001) ✅: conteggio(1) = 36 slot ‖ activityTypeCode[36] (codici attivi poi 00 padding). Seleziona quali sport appaiono nel menu allenamento dell'orologio.
  • HEART_MONITORING_ENABLED (009B 0001) ✅: byte kind — 01 = HR 24/7, 02 = SpO₂, 04 = stress (misurato ogni 30 min).
  • HEART_MONITORING_ALERTS (FFFF 9059) ✅: disabilitato = 00; abilitato = 01 ‖ hrLow ‖ hrHigh ‖ sportHrHigh ‖ spo2Low ‖ 00 00 00 00 (un limite 0/255 = "nessun limite").
  • FEMALE_CYCLE (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).
  • QUICK_REPLY (FFFF 9073) ✅: TLV — conteggio(1) ‖ totale(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]… (7 risposte predefinite catturate e decifrate).
  • WORLD_CLOCK (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)]….
  • MUSIC_INFO_SET (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.
  • WEATHER_SET_1 (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.)
  • FIND_WATCH (005D 0001) ✅: payload 0x01 → l'orologio squilla/vibra (+ ACK 005D 0003).
  • GPS_PUSH (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.
  • WORKOUT_GPS (FFFF A05A) ✅ — little-endian, longitudine prima: 12 byte ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32).
  • TIME (FFFF 8004): vedi §7.
  • cfbppraster (dopo LZ4)uso
    42RGB565-LEsfondo opaco (FULL/THUMB)
    53RGB565-LE (2 B) + alpha (1 B) per pxsprite anti-alias (glifi, mani, icone)
    13 (0x0d)0.5maschera alpha a 4 bit; il firmware sfuma a runtimeatlante glifi cifre
    24 (0x18)4RGBA8888livelli a colori reali (incl. il sempre acceso aodImage)
    1—JPEG/JFIF (ff d8 ff), estraibile con qualsiasi decoderrari fotogrammi di animazione
    0a
    0x61
    NotEnvelope
    0a
    ChildOverflow
    :
    61 0a 00
    −18/−16
    write offsets must move too
    0a
  • Multi-variant complication slots: the active metric is NOT in the .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.
  • Edge-anchored inactive complications (e.g. bpm text at (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.
  • hands
    0x22
    aod
    0x22
    @69,209
    isolated Normal|AOD editing UI
  • Standalone 0x60 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.
  • deccarddeccard
    0empty slot49–53,97–98Weather
    1–6Steps54–58Timer
    7–12Calories59–62Breathing
    13–18Stand63,65–67Stopwatch
    19–24Moderate activity68–71Battery
    25–30Heart rate72–76Recents
    31–36SpO₂77–81Contacts
    37–42Stress82–86Dial / phone
    43–48Sleep87–91Sport (sportId ≠ 0)
    92Music93/94/96Activity record / PAI / Cycle