Skip to content
KitploitKITPLOIT
OutilsBlog
Soumettre
OutilsBlog
Soumettre

Outils de Hacking, PenTest et Cybersécurité pour votre Arsenal de Sécurité !

Kitploit est un répertoire d'outils de hacking, de cybersécurité et de pentesting. Découvrez les dernières mises à jour des projets pour trouver des vulnérabilités, analyser des systèmes, automatiser les tests et renforcer votre sécurité.

··Flux·Contact·Confidentialité·© 2026 Kitploit

Répertoire d'outils

Catégories

Voir toutes les catégories
Loading categories
CMF-Watch-Pro-2-BLE-Protocol — Protocole BLE rétro-conçu pour la CMF Watch Pro 2, documentant la disposition GATT, les trames de commande chiffrées en AES-128-CBC, l'échange d'authentification et la synchronisation des données de santé pour le développement d'une application compagne alternative. | Kitploit
Outils/GitHubGitHub/joshuapassos/cmf-watch-pro-2-ble-protocol
Sécurité des Systèmes EmbarquésSécurité BluetoothSécurité IoTRétro-ingénierieSécurité Sans FilCryptographieSécurité MobileSécurité Matériel et IoTAnalyse de Micrologiciel

Populaires

Voir tout →

Découvrez les outils les plus utilisés par notre communauté.

Explorer tous les outils

Parcourez notre collection d'outils

Voir tous les outils →
Partager
GitHubjoshuapassos/cmf-watch-pro-2-ble-protocol

CMF-Watch-Pro-2-BLE-Protocol

Protocole BLE rétro-conçu pour la CMF Watch Pro 2, documentant la disposition GATT, les trames de commande chiffrées en AES-128-CBC, l'échange d'authentification et la synchronisation des données de santé pour le développement d'une application compagne alternative.

Voir le dépôtSite web
3il y a 24 joursPas encore vérifié

CMF Watch Pro 2 — Protocole BLE (rétro-conçu)

Non officiel. Ce document décrit le protocole Bluetooth Low Energy (BLE) de la CMF Watch Pro 2 (CMF by Nothing), reconstitué par rétro-ingénierie pour une application compagnon alternative. Il n'est pas affilié à ou approuvé par Nothing/CMF. À utiliser à vos risques et périls.

Tous les entiers multi-octets dans l'en-tête de trame et les opcodes sont en big-endian. Les entiers dans les charges utiles des commandes sont en little-endian sauf indication contraire (cela reflète le firmware de l'appareil) — attention aux exceptions (GOALS_SET, GPS_PUSH, le décalage/longueur du transfert en bloc sont en big-endian).

Indicateurs de confiance

Chaque affirmation non évidente ci-dessous est étiquetée avec la manière dont elle a été établie :

  • ✅ validé sur l'appareil — observé dans une capture en direct déchiffrée ou testé sur une vraie montre.
  • 🔎 issu du firmware / de l'APK RE — extrait par décompilation du firmware (1.0.0.73) ou de l'APK officiel (3.5.7) ; cohérent avec le code mais pas testé à l'exécution.
  • ⚠️ [incertain] — déduit, non confirmé ; peut être erroné.

Appareil de test pour toutes les captures : CMF Watch Pro 2-5485, firmware 1.0.0.73, numéro de série CI04102520008192, MCU Actions ATS3089C (Cortex-M4), écran 466×360.


1. Disposition GATT

Le téléphone est le client GATT ; la montre est le périphérique, qui se présente sous le nom CMF Watch Pro 2-XXXX (4 caractères hexadécimaux).

Activez les notifications en écrivant 01 00 sur chaque CCCD (00002902-…). Le canal de commande (fff1/fff2) transporte le protocole encadré ci-dessous. Le canal shell (77d4…) transporte du texte simple de style AT (par exemple AT GETSECRET ; voir §14). Le canal de données (02f0…) transporte de gros blobs binaires (cadran de montre, firmware, AGPS), coordonnés par des opcodes de contrôle sur le canal de commande.

✅ Une session réelle complète s'est déroulée sur le seul canal de commande — pendant une capture de 160 secondes d'utilisation intensive, il n'y avait aucun trafic sur les canaux de données/firmware ou shell, sauf lors d'un transfert OTA/cadran de montre explicite.


2. Format de trame (0xF5)

Chaque message du canal de commande est encapsulé dans une ou plusieurs trames d'en-tête de 11 octets :``` +------+-----------+--------+-------------+-------------+--------+-------------------+ | 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` forment ensemble l'**opcode** (voir §6). 🔎 confirmé par rapport au constructeur de trames de l'application officielle (`C6117b.m30831g`).
- `chunkCount` = nombre total de fragments pour cette commande ; `chunkIndex` est **basé sur 1**.
- `chunkLen` = nombre d'octets de `chunk` dans cette trame.
- Une seule écriture BLE peut être fragmentée par le MTU du lien ; le récepteur met en tampon les octets bruts et réextrait les trames complètes. Les charges utiles volumineuses sont divisées en plusieurs fragments (même `cmd1/cmd2`, `chunkIndex` croissant) et réassemblées dans l'ordre.

### Convention d'opcode (✅ confirmé sur le fil)

- `cmd1 = 0xFFFF` : `cmd2` dans `0x80xx`/`0x90xx` = téléphone→montre (requête/définition) ; `0x00xx`/`0xa0xx` = montre→téléphone (réponse). Les paires correspondent par l'octet bas (`0x9055`↔`0xa055`, `0x8051`↔`0x0051`).
- `cmd1` spécifique à une fonctionnalité : suffixe `cmd2` = `0x0001` **SET**, `0x0002` **GET**, `0x0003` **ACK**.

### Corps du fragment

Pour chaque fragment, le corps est `payloadPiece ‖ CRC32_LE(payloadPiece)` (CRC 4 octets, petit-boutiste, zlib/IEEE). Si la commande est **chiffrée** (voir §3), l'ensemble `payloadPiece ‖ CRC` est ensuite chiffré en AES-128-CBC/PKCS7 et ce texte chiffré devient le `chunk` de la trame.

**Bizarrerie du texte clair :** pour les opcodes en texte clair, la montre *compte* le CRC de 4 octets dans `chunkLen` mais ne le **transmet pas**. Ainsi, lors du décodage d'une trame en texte clair, la longueur réelle des données est `chunkLen − 4`. (Les trames chiffrées transportent le CRC à l'intérieur du texte chiffré comme d'habitude.)

Dimensionnement des fragments (afin que les fragments chiffrés tombent sur les limites de blocs AES), avec `maxWrite = mtu − 3` :
- chiffré : `floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- texte clair : `maxWrite − 11 − 4 − 2`

✅ Toutes les valeurs observées de `chunkLen` pour les trames chiffrées étaient des multiples de 16 (l'alignement des blocs est respecté).

---

## 3. Primitives cryptographiques

- **AES-128-CBC** avec rembourrage **PKCS7** et un **IV fixe** (provenant du firmware `CmfCharacteristic.AES_IV`) :
  `50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 5A`.
- **CRC32** (zlib/IEEE), émis sur 4 octets en petit-boutiste.
- **SHA-256** sur la concaténation des parties.

Dérivation de clé :```
authkey      = SHA256( rnd1 ‖ rnd2 ‖ secret )[0..16]      // persisted across sessions
sessionKey   = SHA256( nonce ‖ authkey )[0..16]           // per connection
  • secret = secret de l'appareil de 16 octets (obtenable depuis la montre via la commande shell AT GETSECRET → GETSECRET:<32-hex>,OK).
  • rnd1 = 16 octets aléatoires choisis par le téléphone ; rnd2 = 16 octets aléatoires de la montre.
  • nonce = octets de la réponse nonce de la montre.

Après que la clé est définie, toutes les trames du canal de commande sont chiffrées en AES sauf les opcodes en clair listés au §5.

✅ Les deux dérivations validées : authkey récupérée depuis la base ntwatch.db d'un téléphone rooté correspond à la valeur dérivée d'un rnd1/rnd2/secret capturé ; sessionKey reproduite à partir d'un nonce capturé déchiffre les trames en direct.


4. Authentification / processus d'appairage

Deux chemins d'entrée partagent la même terminaison nonce/confirm.

4.1 Premier appairage (avec le secret de l'appareil)```

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:~
Sur `AUTH_FAILED (0xFFFF,0xA061)` ou une discordance de signature, l'authentification échoue.

### 4.2 Reconnexion (authkey déjà connu)```
        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'ordre de reconnexion (aucun trafic shell) a été observé intact sur une capture réelle.

4.3 Init post-authentification (phase 2)

⚠️→✅ TIME est obligatoire avant les requêtes de données. Après Initialized, la montre ne répondra pas à BATTERY, SERIAL_NUMBER_GET, ni à la poignée de main ACTIVITY_FETCH_* jusqu'à ce qu'un TIME (FFFF 8004) ait été envoyé dans la session — sans cela, seul un FIRMWARE_VERSION_RET non sollicité arrive et tout le reste expire. ✅ confirmé en direct (Pixel 8a) : envoyer les trois GET sans TIME → seules les réponses du firmware ; envoyer TIME d'abord → la batterie et le numéro de série commencent à répondre.

Ordre recommandé de la phase 2 : TIME → FIRMWARE_VERSION_GET → SERIAL_NUMBER_GET → BATTERY (0xA5) → poussées de configuration → synchronisation santé (§8).

4.4 Motif d'écho GET → SET (✅)

Il n'existe pas d'opcode de « lecture » distinct pour la plupart des réglages. Envoyer un *_GET (cmd2 = 0x0002, payload 0xA5) fait répondre la montre avec l'opcode SET (cmd2 = 0x0001) contenant la valeur actuelle. Les commandes SET sont acquittées avec cmd2 = 0x0003 et un corps vide.


5. Texte clair vs chiffré

Les trames sont chiffrées par AES une fois qu'une clé est définie, sauf ces opcodes, qui sont toujours en texte clair :

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

Les en-têtes des trames (cmd1/cmd2) voyagent toujours en clair, donc la séquence des commandes est visible dans toute capture même sans la clé — seuls les chargements utiles chiffrés nécessitent sessionKey.


6. Référence des opcodes (cmd1, cmd2)

GET/SET/REQUEST = téléphone→montre ; RET/REPLY/ACK/RESPONSE/DATA = montre→téléphone.

Session / périphérique

Authentification

Notifications / appel / recherche

Musique

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

Alarmes / contacts / rappels

Configuration

Météo

Nomcmd1,cmd2
WEATHER_SET_1 (celui qui fonctionne)FFFF 906B
WEATHER_SET_2 (ignoré sur Pro 2 — voir §9)0066 0001

Cadrans / fonds d'écran

Santé / synchronisation

Opcodes JS uniquement (FFFF 8051, FFFF 0051, FFFF 90A2, FFFF 90C5, FFFF A056, FFFF 908A/908B état/support ChatGPT) sont traités dans le bytecode Hermes de l'application, pas dans la couche Java. Leurs en-têtes apparaissent dans les captures mais la sémantique des charges utiles est ⚠️ [incertaine].

Transfert de données en masse (canal de données)

Les cadrans / firmware / AGPS utilisent une boucle init → demande-chunk/écriture-chunk → fin-accusé :

(tous cmd1 = FFFF.) La montre pilote la boucle en émettant DATA_CHUNK_REQUEST_*(offset, length) (offset/length = u32 big-endian) ; le téléphone répond avec DATA_CHUNK_WRITE_* transportant payload[offset..offset+length] sur la caractéristique de données. Voir §11–§12 pour les détails.


7. Heure et fuseau horaire

TIME (FFFF 8004) payload = epochSecondes(i32, BE) ‖ decalageUTCms(i32, BE). Envoyé juste après l'authentification pour que la montre affiche l'heure locale (et débloque les requêtes de données — voir §4.3).

⚠️ Les horodatages de santé de la montre sont UTC. L'application compagnon doit ajouter le décalage UTC local avant de déduire le jour calendaire / heure locale. (Grouper la santé par jour UTC brut fait basculer le jour à une mauvaise heure locale.)

TIME_FORMAT (005F 0001) payload = 1 octet : 00 = 24h, 01 = 12h.


8. Synchronisation santé

  1. Le téléphone envoie ACTIVITY_FETCH_1 ; la montre répond ACTIVITY_FETCH_ACK_1 (premier octet 01 ⇒ prêt).
  2. Le téléphone envoie ACTIVITY_FETCH_2 ; la montre envoie alors une rafale de trames de données : ACTIVITY_DATA, HEART_RATE_*, SPO2, STRESS, SLEEP_DATA, WORKOUT_SUMMARY[_V3].
  3. Chacune est analysée en échantillons par minute / sessions et agrégée par jour local.

La synchro est séquentielle (doit suivre TIME ; la montre libère les flux après ACK_2), pas une rafale unique. Une session lourde envoie ~170–210 trames de notification en ~160 s. ✅

8.1 Enregistrement d'activité — ACTIVITY_DATA (32 octets chacun, LE) ✅

Unité de calorie : les calories d'activité sont rapportées en cal (calories-grammes). Divisez la somme quotidienne par 1000 pour obtenir kcal. (Les calories du résumé d'entraînement, en revanche, sont déjà en kcal.)

8.2 Échantillons FC / SpO₂ / Stress ✅

  • FC manuelle/auto, FC d'entraînement, SpO₂, stress = 8 octets chacun : horodatage(i32 LE) ‖ valeur(i32 LE) (valeur = bpm / % SpO₂ / indice de stress).
  • FC au repos (00DA 0001) est différent — 5 octets : horodatage(i32 LE) ‖ fc(u8). ✅ exemple en direct 5e dc 29 6a 4e → ts, fc = 78 bpm. Plage de score de stress : 1–29 / 30–59 / 60–79 / 80–99.

8.3 Sommeil — SLEEP_DATA (en-tête de 18 octets + N × 8 octets d'enregistrements) ✅

Un SLEEP_DATA = une session de sommeil ; une nuit peut en contenir plusieurs (les micro-réveils fractionnent les sessions).

En-tête :

Chaque enregistrement de 8 octets : horodatage(u32) ‖ duree_s(u16) ‖ stade(u16). Codes stade : 1 = Profond, 2 = Léger, 3 = REM, 4 = Eveillé. ✅ validé sur une nuit complète (deux sessions, totaux P/L/R/E concordants).

8.4 Résumé d'entraînement — WORKOUT_SUMMARY v1 (54 octets) / _V3 (0160 0001)

v1 : debut(u32), fin(u32), duree_s(u32), puis type/calories/pas/distance/FC-moy et un bloc GPS/étendu. ✅ disposition v1 confirmée par rapport au firmware. WORKOUT_SUMMARY_V3 est une disposition plus récente pour les mêmes données plus un bloc étendu d'environ 40 octets (charge d'exercice, aérobie/anaérobie, temps de récupération, VO₂max, cadence, PAI, meilleurs temps de course…). Le jeu de champs est connu (à partir de la base de données Room de l'appli) mais les décalages exacts des octets dans ce bloc de 40 octets sont ⚠️ [incertains] — les fermer nécessite une capture brute d'un entraînement GPS.


9. Charges utiles de commandes sélectionnées

Les chaînes sont en UTF-8, tronquées par octet à la taille du champ (la troncature peut couper un caractère multi-octet, correspondant au comportement s.encode()[:max] du firmware) ; les champs courts sont complétés de zéros à droite.

  • APP_NOTIFICATION (0065 0001) ✅ : codeIcone(1) ‖ 0x00 ‖ quand(u32 BE) ‖ tailleTitre(1) ‖ titre ‖ corps. codeIcone sélectionne l'icône de l'application (WhatsApp=8, Telegram=12, Instagram=18, Gmail=27 ; inconnu=0xFF). Titre ≤ 20 octets, corps ≤ 128 octets. Envoyé depuis un client → la montre l'a affiché + ACK 0065 0003.
  • BATTERIE (005C 0001) ✅ : réponse = niveau(1) ‖ charge(1) (ex. 3b 00 = 59 %, pas en charge).
  • SERIAL_NUMBER_RET (00DE 0001) ✅ : longueur(1) ‖ ASCII (ex. 10 + "CI04102520008192").
  • INFO_UTILISATEUR (0095 0001) ✅ : taille_cm(1) ‖ poids_kg(1) ‖ age(1) ‖ genre(1: 1=M) (ex. = 172 cm / 73 kg / 31 / homme).

10. Notes d'implémentation et bizarreries

  • Pas d'horloge système dans les codecs : les encodeurs prennent maintenant/decalage_utc comme paramètres explicites (déterministe, testable). Le transport fournit le temps réel.
  • TIME verrouille tout (§4.3) — envoyez-le en premier sinon la montre reste muette sur les requêtes de données.
  • Comptage CRC en texte clair (§2) est facile à mal faire — les trames en texte clair annoncent mais omettent le CRC.
  • Boutisme : en-tête + opcodes BE ; payload entiers LE ; exceptions — GOALS_SET et GPS_PUSH sont en big-endian, et les offset/longueur de transfert en masse sont en big-endian.
  • MTU : les tailles de chunks sont calculées pour que les chunks chiffrés soient alignés sur des blocs AES de 16 octets.
  • authkey est persistant (stockez-le après le premier appairage) ; sessionKey est par connexion et dérivé du nonce de la montre à chaque reconnexion.

11. Cadrans — création

La montre prend en charge (a) les cadrans photo/personnalisés (une image de fond + une horloge numérique dessinée par le firmware) et (b) les cadrans structurés (fond d'écran intégré / boutiques : un fond plus des couches de sprites positionnés, des aiguilles et des widgets de texte). Les deux transferts passent par le canal de données via la boucle init → chunk dans §6.

Ce qui fonctionne réellement (✅ validé en direct) : construire un cadran photo à partir de n'importe quelle image et l'installer ; installer n'importe lequel des 103 cadrans de la boutique hors ligne ; reskinner un cadran structuré (échanger le fond ou n'importe quel sprite non-fond) et déplacer ses couches ; réorganiser / commuter le cadran actif ; et construire un cadran structuré de zéro — l'enveloppe de scène 0x20 est décodée et le constructeur est implémenté (§11.7), prouvé hors ligne pour faire un aller-retour sur les 103 cadrans de la boutique octet par octet et pour émettre des conteneurs synthétiques qui passent le validateur du firmware. 🟡 la seule étape non prouvée est de voir un rendu synthétique de zéro sur l'appareil via 9075 (la preuve structurale hors ligne couvre déjà ce qui causait le rejet 0a). Il n'y a pas de barrière de codec ou de transport et pas besoin de la chaîne d'outils du fournisseur. Les anciennes affirmations « le rendu structuré est cuit dans le pack RES / impossible en BLE » et « codec côté serveur cf=0x1f » étaient fausses (un bug d'offset+octets par pixel) — le firmware rend les cadrans structurés pilotés par les données à partir du fichier que vous envoyez.

11.1 Gestion des cadrans — DIAL_COMMAND (9055 / a055) ✅

  • type 0 = interroger la liste. Réponse a055 = resultat(u8) ‖ indexSelection(u8) ‖ total(u8) ‖ max(u8) ‖ N × idCadran(u32 LE) ‖ ffffffff. Exemple : 01 05 06 07 … = actif #5, 6 cadrans, max 7.
  • type 1 = réorganiser / sélectionner l'actif : renvoyer toute la liste avec le cadran cible à l'index 0 (c'est ainsi que l'application officielle change de cadran ; il n'y a pas d'opcode « définir actif » dédié).
  • Supprimer un cadran = renvoyer la liste sans son id.
  • CHANGE_DIAL (009F 0001) est inerte sur fw 1.0.0.73 (renvoie une constante, ne change rien) — ne l'utilisez pas.

11.2 Flux de transfert ✅```

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:~
Octet de réponse de fin : `01` = activé et enregistré ; `0a` = stocké mais **non** activé / rejeté. Sur Android, chaque `DATA_CHUNK_WRITE` doit être envoyé comme **une écriture BLE par trame** — concaténer et re-découper par MTU désynchronise les en-têtes et la montre boucle en demandant le décalage 0.

- **`9063` (photo) = AJOUTER.** La liste des cadrans augmente (6→7); `watchfaceId = 0xFFFFFFFF` (sentinelle personnalisée) de sorte qu'il n'est jamais rejeté comme doublon, et la montre l'active automatiquement.
- **`9075` (structuré) = REMPLACER** l'emplacement `old_id`. `old_id` **doit** déjà être dans la liste (sinon `0a`). Pour réinstaller un identifiant déjà présent, **supprimez-le d'abord** (9055 liste-moins-id) puis téléchargez « frais » — réutiliser un identifiant en place donne `0a`.

### 11.3 Photo / cadran personnalisé — ✅ entièrement validé de bout en bout

**Conteneur** (aller-retour vérifié par octet ; tous les champs en 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 counts from the first LZ4 byte). The official app uses LZ4-HC and strips the 21-byte LZ4-block header/footer; a plain literals-only LZ4 encoder also works — the watch accepts any valid LZ4, byte-identity is not required. Les pixels en dehors du cercle inscrit (centre 233,233, rayon 233) sont définis à 0x0000.

INIT_2 for 9063 — en-tête exact (✅ c'est celui qui fonctionne) :``` 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` = longueur exacte de `.bin` ; `FFFFFFFF` = `watchfaceId` personnalisé ; `styleId` 0–4 sélectionne la disposition d'horloge numérique intégrée (elle est toujours dessinée — il n'y a pas d'option « désactivé ») ; `posX/posY` le positionne (valeurs connues 56 / 77) ; `color565` le teinte (par ex. `FFFF` = blanc). ⚠️ La forme plus courte `A5 ‖ size ‖ watchfaceId` est **rejetée** avec finish `0a` — utilisez l'en-tête complet ci-dessus. (Implémentation de référence: `core-rust/engine.rs::build_wf_init2`, miroir de `C6135t.m31104u` dans l'application officielle.)

**Recette:** redimensionner l'image à 466×466 (et une vignette de 270×270), convertir en RGB565-LE du haut vers le bas, éventuellement mettre à zéro les pixels hors du cercle, compresser chacun avec LZ4, assembler le conteneur ci-dessus, et télécharger via le pipeline `9063` avec `watchfaceId = 0xFFFFFFFF`. (Codec de référence: `core-rust/watchface.rs`, `work/codec_dfa.py`.)

### 11.4 Cadran structuré / de boutique — conteneur et codecs ✅

**En-tête** (identique pour les 103 cadrans de boutique ; tous les champs en 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

Il n'y a pas de somme de contrôle bloquante (CRC32/Adler32/somme d'octets échouent tous) — le rempaquetage n'est pas conditionné. Les cadrans factices (~173 o, par ex. ids 273/274/277) sont des espaces réservés pour les faces cuites dans la ROM : en-tête + répertoire, sans actifs réels.

Actifs — chacun est dimsWord(u32 LE) ‖ len(u32 LE) ‖ LZ4(payload), où cf = dimsWord & 0x1f, w = (dimsWord >> 10) & 0x7FF, h = (dimsWord >> 21) & 0x7FF, et len compte à partir du premier octet LZ4 (le 1f 00 01 00 que vous voyez souvent est le premier token LZ4 — ne le sautez pas). Taille décompressée = w·h·bpp :

✅ Tous les 4151/4151 actifs des 103 cadrans se décodent exactement avec un décompresseur lz4.block standard à w·h·bpp. La transparence est l'octet alpha (cf=5/24) ou 0x0000 (cf=4 en dehors du cercle) — il n'y a pas de RLE ni d'« échappement ». Encoder = re-raster → LZ4 standard → [dimsWord][len][LZ4].

INIT_2 pour 9075 — corps chiffré par AES :``` kind(1) ‖ old_id(u32 LE) ‖ new_id(u32 LE) ‖ file_len(u32 LE)

root@kitploit:~
`kind` = `0x02`/`0x03` ; `old_id` = cadran actif actuel (depuis `9055`) ; `file_len` = taille réelle du `.bin`
(= `@0x18 + 36`). Installer un `.bin` de boutique tel quel est le chemin garanti (Ring Data id 359 +
102 autres confirmés). (Référence : `core-rust/engine.rs::build_dial_replace_init`.)

### 11.5 Grammaire de répertoire structurée ✅ (décodée et implémentée — RÉVISÉ le 02/07/2026)

> **⚠️ Révision (02/07/2026) : le schéma plat d'enregistrement `61 01 00` ci-dessous était
> systématiquement DÉCALÉ D'UN.** Le corps de scène est une TLV propre (§11.7) ; un **corps de feuille** dessinable
> (balises `0x30`/`0x38` statiques, pointeur `0x70`) est :
>
> ```
> 01 xx 00 [X u16][Y u16] …attrs… 61 [count u16][base u32][count×id u16] [05 05 00 01 pivX pivY]
> ```
>
> - l'attribut `0x01` ouvre le corps : **X,Y = coin supérieur gauche** sur le canevas 466² (les `s16 x,y` du
>   `sty_picture_t` du SDK).
> - la **table de trames `61 …` ferme le corps** (`base` = pointeur d'actif ; `count` 1 = image, 10/11 =
>   atlas de chiffres — l'ancien « type d'enregistrement `0a/0b` » était en fait ce count ! — 7/13/2 = feuille
>   de trames de complication).
> - suppléments du pointeur : source+échelle `[src] 00 3c 00` à l'intérieur de l'attribut `0x01` ; pivot dans le
>   **suffixe** `05 05 00 01 [pivX][pivY]`. **Centre de rotation = `(X+pivX, Y+pivY)` par pointeur** —
>   pas un point fixe (233,233) : des sous-cadrans décentrés existent (par ex. les aiguilles du cadran 366
>   tournent autour de 150,150).
>
> Le balayage linéaire de `61 01 00` cousait la table de trames + pivot de l'élément **N** aux X/Y
> (et à l'octet de balise, l'ancien « f3 ») de l'élément **N+1** — cela ne semblait correct que sur les
> cadrans analogiques dont les aiguilles adjacentes partagent une géométrie quasi identique. Le « mur des
> variantes compactes » (spec 24 §24.4.5) était cette même erreur de lecture. Implémenté comme
> `scan_scene_drawables` dans `core-rust/watchface_struct.rs` et `wfweb/src/codec/parse.ts`
> (scène = source principale pour images/pointeurs ; balayage plat conservé pour le texte et le repli
> sans enveloppe). Validé par l'oracle `wfweb/compare.html` (rendu vs PNG officiels de la boutique,
> 99 cadrans) : 64→72 bons, 8→5 mauvais, différence moyenne 9,3→7,4%.

Lecture historique d'enregistrement plat (remplacée, conservée pour contexte) :

- **Image statique** (`61 01 00`) : `asset_ptr(u32) ‖ elemId(u16) ‖ 05 05 00 01 ‖ pivotX(u16) ‖
  pivotY(u16) ‖ 3B ‖ 01 ‖ 1b 00 ‖ X(u16) ‖ Y(u16)`. Coin supérieur gauche sur le canevas 466² = `(X−pivotX, Y−pivotY)`.
- **Pointeur/aiguille** — même enregistrement d'image, tourné en cours d'exécution. **Centre de rotation = `(X+pivotX, Y+pivotY)`**
  (≈ 233,233 sur les cadrans analogiques). La **source de données est un `u8` au décalage d'enregistrement `+36`**, échelle `u16` à
  `+38` (=60) : `0x0a`/`0x70` = heure (`h·30°+m·0,5°`), `0x0e`/`0x71` = minute (`m·6°+s·0,1°`),
  `0x12`/`0x72` = seconde (`s·6°`). ✅ confirmé par désassemblage des accesseurs (repli RTC 10:10:30).
- **Texte / widget numérique** (`61 0a 00`) : `asset_ptr(u32) ‖ [10×u16 font metrics] ‖ 40 01 00 ‖ indicateur ‖
  3B ‖ 01 ‖ u16 ‖ X(u16) ‖ Y(u16)`. `asset_ptr` pointe vers le glyphe « 0 » ; le chiffre *d* = l'actif à
  `index("0") + d` (10 sprites consécutifs cf=5, par ex. `0123456789` et ponctuation `,°`). ✅ rendu.
- **Remplissage de complication = index de trame** (✅ confirmé pour les complications à chiffres/énumération/jauge, count>1) :
  la valeur indexe une **feuille de trames pré-rendue** dans le `.bin` — `trame = (count−1)·val/100` (pourcentage) ou
  `trame = valeur` (chiffre à bascule/énumération). Table de trames = sous-enregistrement `61 ‖ count(u16) ‖ base(u32) ‖
  count×id(u16)`. Par ex. le grand chiffre du 327 Digit Max est une feuille de 13 trames (chiffres 0–12), `trame = heure`.
- **Anneau de progression / arc = coupe sectorielle en cours d'exécution** (✅ 02/07/2026, **corrige la lecture
  « les anneaux sont des feuilles de trames » dans la spec 25 §2**) : la balise d'élément **`0x81`** porte un
  **unique** disque complet (`61` table de trames `count == 1`), et le secteur partiel est ce disque
  **clippé à un secteur circulaire** (`frac = valeur/max`, dans le sens horaire depuis 12 heures) — vérifié
  pixel pour pixel sur 322 Glare 2 et confirmé `count==1` sur **20 cadrans**. Sur le disque : le corps `0x81` =
  sous `0x01` (géométrie `x@+0 y@+2 l@+4 h@+6`, `61 1 base` en ligne = disque) + sous `0x5b` (`max` u16 `@+4`,
  =100 sauf 332=60). Implémenté dans wfweb (`blendSector`).
- **Identifiant de source de données** — le bloc d'attributs `82` de l'élément se trouve à `delim+3` (après le dernier
  `40 01 00`), et **l'identifiant source est un `u8` à `+0x14`** (également `relX@+0x07 s16`, `relY@+0x09 s16`,
  `ancrage@+0x0C/0E`, `mode@+0x15`, `nb-trames@+0x1A`). Ancrage < 0 = aligner sur le bord du parent. 🔎 Le
  microprogramme résout l'identifiant via une table d'accesseurs à 142 entrées à `0x101f371c` (chacune appelle
  `ux2sys_get(type)`). Identifiants courants (§16) : `0x07` heure, `0x0b` minute, `0x0f` seconde, `0x16` mois,
  `0x18` jour de la semaine, `0x13` AM/PM, `0x19` FC, `0x1b` % batterie, `0x24` température, `0x36` pas,
  `0x70/71/72` angles d'aiguilles, `0x25–27` % d'objectif. (Cela correspond au groupe `0x07:0x0b:0x0f` =
  HH:MM:SS ci-dessous.)
- **Nœud de groupe** (`0x68`) : imbrique ses enfants dans son propre corps TLV (`0x60` = valeur/texte,
  `0x30` = statique) ; chaque `0x60` porte son identifiant source à `données+16`. Par ex. un groupe `0x07:0x0b:0x0f` =
  horloge HH:MM:SS. Analyseur d'élément TLV = `0x100db55c` (table de saut indexée par `balise−0x70`).

### 11.6 Matrice de création

| Chemin | Statut | Remarques |
|---|---|---|
| Cadran photo à partir de n'importe quelle image | ✅ **fait** | §11.3 ; validé sur l'appareil |
| Installer l'un des 103 cadrans de la boutique | ✅ **fait** | §11.4 ; `9075`, `old_id`=actif |
| Repeindre le fond cf=4 d'un cadran de boutique | ✅ **fonctionne en direct** | échanger la charge utile COMPLÈTE en place, définir la `len` de l'actif sur la **nouvelle** taille de bloc (≤ ancienne), conserver la même empreinte fichier, nouvelle installation |
| Recréer par gabarit (échanger les pixels de n'importe quelle couche + déplacer la géométrie) | ✅ **rendu via BLE** | cadran 373 : fond→cyan + un sprite cf=5→rouge + X déplacé 224→100, tout rendu, aiguilles en direct |
| Cadran structuré synthétique 100% de zéro | ✅ **constructeur fait, validé hors ligne** | constructeur d'enveloppe `0x20` dans `watchface_struct.rs` (`build_container`/`serialize`/`validate_container`) ; fait l'aller-retour de tous les 103 cadrans octets identiques + synthétique passe le validateur du microprogramme (§11.7). 🟡 rendu sur l'appareil via `9075` pas encore filmé |
| Polices système (`.font`) | ✅ **décodage/rendu (tous)** | LVGL bin (pas propriétaire) ; 32 polices numériques (`num*/nm*`, non compressées) + 24 polices texte (`font*`, LVGL RLE `comp=1`) toutes décodées — 12208 glyphes, 0 dépassements, ASCII complet. RLE = LVGL v8.3 `lv_font_fmt_txt.c` (3 états SINGLE/REPETE/COMPTEUR + préfiltre XOR par ligne), porté 1:1, pas de désassemblage |

⚠️ Pièges de repeint/recreation qui causent un écran noir ou `0a` : laisser l'**ancienne `len` d'actif** (la
montre lit au-delà du bloc → dépassement → noir) ; **agrandir le fichier** (rejeté à l'installation) ; réutiliser un
identifiant **en place** au lieu d'une nouvelle installation.

### 11.7 L'enveloppe de scène `0x20` — décodée et constructeur implémenté ✅

Un cadran **réel** recréé rend car il préserve l'enveloppe de scène du fichier. Un corps purement synthétique
d'enregistrements `61 …` plats est **rejeté** — l'analyseur du microprogramme (`WFManager_Parser`, `0xdb35c`)
exige que le corps (à partir du décalage `0x24`) commence par un conteneur de scène `0x20`. Le fichier complet est :```
[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 scène est une TLV imbriquée propre — [tag u8][len u16 LE][body], les balises de conteneur 0x20/0x21/0x22/0x68 récursives, les drawables feuilles 0x30 (statique) / 0x70 (élément/pointeur) / 0x80 / 0x81 / 0x86 (nom). (Les enregistrements plats 61 01 00 / 61 0a 00 sont des motifs qui vivent à l'intérieur des corps des drawables ; l'ancien analyseur les trouvait heuristiquement — et cousait les corps adjacents ensemble, voir la révision §11.5. La disposition du corps du drawable est maintenant entièrement décodée ici.) Chaque offset+len d'un enfant doit tenir dans la fenêtre de son parent ; premier octet du corps ≠ 0x20 → erreur du parseur −16 ; un enfant débordant de sa fenêtre → −2 ; l'un ou l'autre fait que le gestionnaire 9065 (0xeb50c) écrit finish .

Builder est implémenté et validé hors ligne (core-rust/watchface_struct.rs : SceneNode / serialize / parse_scene / validate_container / build_container / build_container_raw ; CLI cmfwatch-wfgen reframe) :

  • scene_roundtrip_identity — tous les 103 cadrans du store : parse_scene→serialize reproduit la scène octet pour octet (les len imbriqués recalculés correspondent) et validate_container réussit sur chacun.
  • build_reframe_identity / CLI reframe — le réassemblage du .bin complet à partir de zéro reproduit le fichier octet pour octet sauf 1 octet de remplissage de nom (@0x17 ; pas une somme de contrôle).
  • build_container_synthetic — compose un nouveau cadran (fond + drawable imbriqué dans 20→21) qui passe exactement l'invariant du firmware (build_container émet des fenêtres imbriquées correctes).

🟡 Encore non prouvé (nécessite la montre, non bloquant) : télécharger un synthétique créé de toutes pièces via 9075 et le regarder s'afficher — la preuve structurelle hors ligne couvre déjà ce qui a causé le rejet 0a.

11.8 Affinements de la fidélité du rendu (02/07/2026, cadran 275 « SlopeTime »)

J'ai recoupé le rendu wfweb avec les miniatures officielles du store (oracle de pixels sur les 103 cadrans) et comblé quatre lacunes :

  • Les X/Y des drawables/pointeurs sont des i16 (signés). ✅ Les ancres peuvent être négatives pour les éléments qui s'étendent hors du canevas — par exemple, l'aiguille rouge des secondes du cadran 275 se trouve à Y = 0xFFFC = −4 (un sprite 30×281, source 0x12, tourné à partir du centre hors du bord supérieur). Lire X/Y comme u16 (65532) faisait que la garde le supprimait. Analyser les deux comme signés et autoriser une petite plage négative.
  • Les chiffres de l'horloge numérique peuvent être des 0x60 img_numbers de premier niveau (pas seulement à l'intérieur d'un groupe 0x68), et la véritable source de données est le u8 à l'offset d'enregistrement −5 — le scan avant de l'attribut 82 est systématiquement décalé d'un ici et saisit l'attribut du frère suivant (dans 275, le chiffre des minutes a pris le jour de la semaine 0x18). Le « 10:10 » du 275 = heure 0x07@X≈306 + min @X≈369 avec les comme statique adjacent entre eux, chacun un atlas de 11 glyphes (). ⚠️ Lors de la correction des X/Y de , les , sinon une réexportation corrompt ces octets (casse la même empreinte → ).

Aussi : les miniatures officielles du store sont rendues à 10:10 (heure marketing classique), pas 10:12 — faire correspondre l'heure de l'oracle à 10:10 réduit la différence de pixels de manière notable. L'analyseur de wfweb fait maintenant un round-trip de tous les 103 cadrans byte-exact (la correction du décalage d'écriture X/Y ci-dessus a résolu les dernières divergences).

11.9 Saut du conteneur AOD + source d'img_number autonome (03/07/2026, cadran « Gradient »)

  • Séparez le conteneur AOD 0x22 dans sa propre vue. ✅ Le parcours de scène ignore déjà 0x22, mais le scan plat de texte/nombre parcourait tout [0x30, firstAsset) — il émettait donc la variante toujours allumée (AOD) de chaque élément comme une couche normale. Sur « Gradient », l'atlas de date gris AOD (décalage dans 0x22) se dessinait par-dessus la date rouge normale. Correction : marquer chaque enregistrement 0x22 avec layer.aod=true (avec son propre ensemble de déduplication) et laisser renderAt(…, aod) ne les afficher qu'en mode AOD (le mode normal cache les couches aod ; le mode AOD cache les couches normales ; l'arrière-plan est échangé par setAod et se dessine toujours). Gain net de l'oracle en mode normal sur le corpus (284 : 31%→21%, +18 autres) — les variantes AOD surchargeaient de nombreux cadrans — et le basculement AOD de l'éditeur montre maintenant la vraie disposition toujours allumée au lieu de la normale. Le vrai AOD est un écran noir (pas de scène atténuée) : si le cadran n'a pas de cadre d'arrière-plan AOD dédié (dial.aod), la scène normale est cachée en mode AOD, donc il rend noir + les éléments avec leur propre couleur. Les AOD sont également analysées via le parcours de scène (il parcourt maintenant la balise en marquant les drawables , au lieu de les laisser au scan plat où leur pivot ne correspondait pas → « non positionnées ») ; les aiguilles AOD tournent au centre du canevas (le porte parfois une position x/y d'aiguille décentrée que le firmware ignore — par exemple, l'heure de Gradient à ). L'éditeur expose également cela comme une (§UI) : chaque écran n'affiche que ses propres couches et les modifications persistent indépendamment. Le rendu en mode normal est byte-identique dans tout le corpus ; le round-trip reste byte-exact sur les 103 cadrans.

11.10 NOMBRE DE CHIFFRES img_number — l'octet 40 01 00 XX (✅ confirmé par le firmware)

Le nombre de chiffres qu'un img_number dessine est un seul octet dans l'enregistrement de champ — l'octet de données XX du sous-enregistrement d'attribut 40 01 00 XX de l'élément (le sous-enregistrement 0x40 qui se trouve après la table de trames 61 [count][base][glyph-ids]) :

  • nibble bas XX & 0x0F = nombre d'emplacements de chiffres (0 ⇒ valeur par défaut du firmware 7).
  • bit 7 0x80 = zéro-padding (afficher les zéros non significatifs, p. ex. « 09 » vs « 9 »).

Confirmé par désassemblage du firmware (image XIP 0x10000000 ; routine de rendu 0x100d8e60) : NDIG = ldrb[40sub+3] & 0x0F (→7 si 0) ; la valeur est clampée valeur % 10^NDIG et exactement NDIG glyphes sont dessinés MS en premier, les zéros non significatifs supprimés sauf si bit7. Le u16 après la source (60 pour date, 1000 pour kcal) n'est PAS le compte — il alimente seulement l'insertion du séparateur de milliers/millions (cmp #1000/#1000000), c'est pourquoi le modifier ne faisait rien. L'id source non plus ne limite pas.

L'histogramme du corpus sur les 620 champs numériques correspond : les champs à 2 chiffres (heure/min/sec/date/temp/FC) se terminent par 40 01 00 02/0x82 ; kcal …04 ; pas …05 ; les séparations d'horloge à un chiffre 0x81. Donc le champ date 40 01 00 82 = 2 chiffres, zéro-padding — c'est la raison entière pour laquelle une température Fahrenheit rebondie (≥100) était tronquée.

Correction / éditeur : wfweb analyse digitCount/digitZeroPad (+digitCountOff) pour les champs numériques, expose « Digits » + « Zero-pad » dans l'inspecteur, écrit l'octet en place (même empreinte), et l'aperçu clame/pad à digitCount pour refléter le firmware. Donc relier la source d'un champ et définir son nombre de chiffres fonctionne pour n'importe quel champ (p. ex. date→température °F → Digits 3). Oracle normal inchangé (0 régressions, 3 petites améliorations) ; round-trip byte-exact sur les 103 cadrans. (L'hypothèse précédente « Largeur des Digits »/rectW était fausse — la largeur n'est que la disposition, pas le compte.)


12. Détails du transfert en masse et OTA

La table de transfert se trouve au §6. Points supplémentaires confirmés :

  • AGPS/EPO ✅ : le premier morceau écrit commence par l'en-tête ASCII 000000010000…. La boucle complète init → [A05F ↔ 905F]×N → finish a été observée sur le fil (~892 chunks).
  • Firmware OTA (9040–9042, finish 9041) 🔎 : structure cartographiée ; charge utile INIT2 = octets de version (p. ex. 0b 00 00 39 = 11.0.0.57). Non testé sur le terrain (l'application désactive la mise à jour FW ici). Les images du firmware semblent être non signées — l'intégrité est uniquement CRC32 (aucune signature asymétrique observée dans la rétro-ingénierie).
  • ⚠️ Parce que l'OTA et FACTORY_RESET (009A 0001) partagent la session authentifiée, une seule authentification BLE valide suffit pour effacer ou (en principe) bricker la montre. Manipuler avec précaution.

13. Capteurs

✅ Matériel exposé via BLE :

  • PPG optique — fréquence cardiaque (manuelle/auto/entraînement/repos), SpO₂, et stress dérivé de la VRC.
  • Accéléromètre 3 axes — pas, distance, calories, cycles de sommeil, lever de poignet, cadence.
  • GNSS/GPS (assisté AGPS) — trace d'entraînement (WORKOUT_GPS) et push de localisation (GPS_PUSH).

Il n'y a pas de baromètre/altimètre, boussole, gyroscope ou capteur de température cutanée/corporelle. Une thermistance NTC interne (température de la carte/batterie) existe mais n'est lisible que via le canal AT (AT GETNTCTEMP, §14) — le flux d'historique de température cutanée 0155 est vide sur ce SKU.

Détournements de widgets de données (il n'y a pas de véritable API de complications/liens de données — voir §11.5) : les champs de texte existants de la montre peuvent être réutilisés pour afficher des données consultables externes. Disponible ✅ : la chaîne de ville météo (WEATHER_SET_1, p. ex. « BRA 2x1 ARG » apparaissait sur le widget) et les champs titre/artiste de la musique ; la liste de contacts (20 × nom[32]+numéro[25]) fonctionne comme un panneau de données défilant. Tous sont des pushs, pas des complications persistantes.


14. Canal AT usine / shell (77d4ff01 / 77d4ff02)

Un canal de commandes AT en texte brut séparé, indépendant du protocole tramé. ✅ testé en direct :

  • Lecture : AT GETSECRET (secret d'appairage 16 octets), GETVERSION, GETSN, GETNAME, GETPID, GETBATLV (mV bruts, p. ex. 3853mv), GETGSENSOR (accélération brute en g, X=… Y=… Z=…), GETNTCTEMP (°C, NTC interne).
  • Écriture / action : AT SETMOTOR=1 (vibrer le moteur), SETHR/SETHRV/SETSPO2=… (injection de test de capteur), SETLCDSWITCH/SETGPSSWITCH/SETKEYSWITCH.

Les réponses se terminent par ,OK. Les commandes SET* s'exécutent généralement mais peuvent ne pas renvoyer ,OK sur BLE — confirmer au cas par cas.


15. Fonctionnalités verrouillées par le firmware / indisponibles (🔎 rétro-ingénierie du firmware)

Certaines fonctionnalités sont présentes dans le firmware mais désactivées par SKU/région et ne sont pas accessibles depuis le téléphone/BLE — elles nécessitent une modification du firmware, ce qui est hors de portée ici :

  • Voix ChatGPT — porte = identifiant de fonctionnalité ux2sys 0x9e, initialisé depuis NVRAM/EFUSE/région au démarrage ; sur ce SKU, le drapeau de support 908b = 00. Non influençable par téléphone, compte ou BLE (confirmé par expérience + rétro-ingénierie). L'application n'est qu'un relais ; l'audio va du téléphone au cloud Nothing.
  • Tension artérielle — un sous-système complet existe dans le firmware, désactivé par SKU/région.
  • Alipay / paiement NFC — interface utilisateur complète présente, SKU Chine uniquement.
  • Absent dans le matériel/le firmware : ECG, SOS/urgence, NFC générique.

16. Table des getters de complications (🔎 référence interne du firmware)

Pas nécessaire pour construire un client BLE — incluse pour complétude. Le moteur de rendu de cadran du firmware lie chaque emplacement de complication à un id de getter numérique (table de dispatch à 142 entrées). Ids sélectionnés : 0x07 heure, 0x0a angle horloge combiné, 0x0b minute, 0x0f seconde, 0x18 jour de la semaine, 0x19 fréquence cardiaque, 0x1b batterie %, 0x24 température, 0x36 pas, 0x70/0x71/0x72 angle aiguille heure/minute/seconde, 0x25–0x27 % objectif. Les complications en anneau/arc indexent une feuille de trames pré-rendue (par exemple 50 % = trame 50 sur 100), pas un arc pixel par pixel — les trames sont intégrées dans le .bin que vous envoyez (§11.5), donc aucun pack RES externe n'est nécessaire.


Cartes rapides (tuiles d'accueil) — QUICK_CARD (906D) ✅

Les tuiles d'accueil de la montre. Le téléphone choisit seulement quelles tuiles afficher et dans quel ordre — les tuiles sont rendues par le firmware (pas de canal de contenu). Premier octet de la charge utile = sous-commande : 00 = GET, 01 = SET ; les deux utilisent 0x906D (0x906C est listé mais pas utilisé — le requêter expire). Réponse = A06D.

🛑 Envoyer un assemblyId inventé efface les écrans de la montre (il accepte la liste, ne peut pas faire correspondre les ids, n'affiche rien). N'envoyez que les ids que vous lisez via GET ; récupérez via l'application officielle ou une réinitialisation d'usine.

Réponse GET ✅ : status(1) ‖ 00 ‖ N(1) ‖ N × groupe, groupe = tag=01 ‖ K(1) ‖ K×(assemblyId, sportId). Trame réelle : 01 00 04 01 02 5d00 6100 01 03 1900 2e00 2300 01 03 5c00 0400 5a02 01 03 4800 5100 5300 = 4 écrans / 11 cartes (5a02 = Carte Sport, sportId 2).

Emplacements : chaque écran a 4 emplacements. Le type d'une carte définit sa taille — circulaire/carrée = 1 emplacement, rectangle = 2 emplacements. La validation est une simple arithmétique d'emplacements (Σ ≤ 4 par écran) ; aucune carte mutuellement exclusive. sportId est 0 sauf sur les cartes Sport (87–91). Les ids 64 et 95 n'existent pas.

Catalogue assemblyId (chaque type logique = une plage contiguë de 6 variantes de style _0.._5 ; 0 = emplacement vide) :


Les dispositions d'octets ci-dessus ont été reconstruites à partir du firmware (1.0.0.73), de l'APK officiel (3.5.7) et de captures en direct déchiffrées sur un véritable appareil. L'implémentation de référence pour ce projet se trouve dans core-rust/src/{commands,frame,crypto,health,session}.rs (Rust) et les outils Python cmftool/ (pair.py, session.py, wf_codec.py, upload_custom.py, …).

Télécharger l’outil
ButServiceCaractéristiquePropriétés
Commande écriture0000fff0-0000-1000-8000-00805f9b34fb0000fff2-…Write
Commande notification0000fff0-…0000fff1-…Notify
Shell écriture (AT)—77d4ff01-2fe2-2334-0d35-9ccd078f529cWrite
Shell notification (AT)—77d4ff02-…Notify
Données en bloc écriture—02f00000-0000-0000-0000-00000000ffe1Write
Données en bloc notification—02f00000-…ffe2Notify
Nomcmd1,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
Nomcmd1,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
Nomcmd1,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
Nomcmd1,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
Nomcmd1,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/pas en direct)FFFF 9078 / FFFF A078
FEMALE_CYCLE_SET / _RETFFFF 9071 / FFFF A071
SLEEP_CONFIG_SET / _RET (min cible)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
Nomcmd1,cmd2
DIAL_COMMAND_SET / _RET (lister/réorganiser/sélectionner)FFFF 9055 / FFFF A055
DIAL_CONFIG_SET / _RETFFFF 9075 / FFFF A075
CHANGE_DIAL (⚠️ inerte sur 1.0.0.73 — ne pas utiliser)009F 0001
QUICK_CARD_SET/GET / _RET (les deux sur 906D)FFFF 906D / FFFF A06D
Nomcmd1,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 🔎 (vide sur ce SKU)0155 0001 / 0155 0002
WORKOUT_SUMMARY / _V30057 0001 / 0160 0001
WORKOUT_GPSFFFF A05A
DomaineINIT1 req/répINIT2 req/répCHUNK req/écritFINISH acc1/acc2
Cadran (photo)8052/00529063/A063A064/9064A065/9065
Cadran (structuré/commutation)8052/00529075/A075A064/9064A065/9065
Firmware9052/A0529040/A040A042/9042A041/9041
AGPS/EPO905E/A05E—A05F/905FA060/9060
DécalageTailleChamp
04horodatage (s epoch)
44pas
84distance (m)
124calories
1616réservé (observé 0)
DécalageTailleChamp
04debut_session (epoch, UTC)
44reveil (epoch, UTC)
82total_profond_s
102total_legere_s
122total_rem_s
142total_eveille_s
162⚠️ [incertain] (id session / score ? les valeurs observées ne correspondent pas aux sommes des enregistrements)
ac 49 1f 01
  • CONTACTS_SET (00D5 0001) ✅ : N × 57 octets = nom(32) ‖ téléphone(25). L'interface de la montre affiche jusqu'à 20.
  • ALARMES_SET (0063 0001) ✅ — corrige Gadgetbridge (qui mettait l'étiquette à la fin, complétée par 0xff — faux). 40 octets par alarme, big-endian : secondesDuJour(i32) ‖ index(u8) ‖ activée(u8) ‖ masque-répétition(u8) ‖ drapeau(u8) ‖ etiquette[32] UTF-8. L'étiquette est au décalage 8 et s'affiche sur la montre. répétition = masque de bits des jours de la semaine (0 = unique) ; drapeau est ⚠️ [incertain] (marqueur unique ?). Exemple (13:30, idx 2) : 0000bdd8 02 01 15 00 "Alarme…".
  • GOALS_SET (005E 0001) ✅ — l'application officielle et l'implémentation de référence utilisent le 10 octets, big-endian DailyTargetBean v1 : pas(u32 BE) ‖ distance_m(u32 BE) ‖ calories_kcal(u16 BE). (C'est la forme Gadgetbridge ; les rapports précédents selon lesquels la montre « ignorait » étaient un bogue de déchiffrement de session obsolète, pas un problème de charge utile.) 🔎 La rétro-ingénierie du firmware montre aussi une variante étendue 29 octets plus longue (ajoute min_sommeil/min_exercice/h_debout + 6 drapeaux d'activation, tous u32 BE après un préfixe drapeau(u16 LE), avec des plages appliquées : pas 2000–30000, dist 1000–99000, cal 100–5000, sommeil 360–720, exercice 30–90, debout 6–16) — ce n'est pas le chemin par défaut de l'application ; préférez la forme à 10 octets sauf si vous avez besoin des objectifs supplémentaires.
  • STANDING_REMINDER / WATER_REMINDER (0060/0061 0001) ✅ : 11 octets : activé(1) ‖ seuil_min(u16 LE) ‖ debutNP(u32 LE) ‖ finNP(u32 LE). Notez que la « fenêtre active 08:00–22:00 » affichée dans l'interface est une valeur par défaut fixe du firmware et n'est pas transportée dans la charge utile.
  • SPORTS_SET (00DC 0001) ✅ : compte(1) = 36 emplacements ‖ codeTypeActivite[36] (codes actifs puis remplissage 00). Sélectionne les sports qui apparaissent dans le menu d'entraînement de la montre.
  • HEART_MONITORING_ENABLED (009B 0001) ✅ : octet type — 01 = FC 24h/24, 02 = SpO₂, 04 = stress (mesuré toutes les 30 min).
  • HEART_MONITORING_ALERTS (FFFF 9059) ✅ : désactivé = 00 ; activé = 01 ‖ fcBas ‖ fcHaut ‖ sportFcHaut ‖ spo2Bas ‖ 00 00 00 00 (une limite 0/255 = « pas de limite »).
  • CYCLE_FEMININ (FFFF 9071) ✅ : 01 ‖ predictionOuverte ‖ notifActiver ‖ cycleDebutActiver ‖ cycleDebutNotifAvant ‖ ovulationDebutActiver ‖ ovulationDebutNotifAvant ‖ fertileDebutActiver ‖ fertileDebutNotifAvant ‖ periode(1) ‖ cyclePeriode(1) ‖ dateDebutCycle(u32) ‖ debutMarquage(u32) ‖ finMarquage(u32) (capturé : periode=5, cyclePeriode=0x1c=28).
  • REPONSE_RAPIDE (FFFF 9073) ✅ : TLV — compte(1) ‖ total(1) ‖ [id(1) ‖ long(u16 LE) ‖ msg-UTF8]… (7 réponses par défaut capturées et déchiffrées).
  • HORLOGE_MONDIALE (FFFF 906F) ✅ : envoie des ID de ville numériques, pas des noms (01 ‖ compte ‖ idVille(2 BE)…) ; la montre mappe les id depuis une table interne. Config DST FFFF 9083 = compte ‖ [id(u16 LE) ‖ dst(u16 LE) ‖ debut(u32 LE) ‖ fin(u32 LE)]….
  • MUSIC_INFO_SET (FFFF 905C, 131 B) ✅ : etat(1: 0=aucun/1=pause/2=lecture) ‖ volume(1) ‖ volumeMax(1) ‖ titre(64) ‖ artiste(64). La montre renvoie également MUSIC_BUTTON (A05D).
  • WEATHER_SET_1 (FFFF 906B, 199 B) ✅ — utilisez celui-ci : 7×9 octets jours + 24×2 octets heures + ville(32) + 7×8 octets lever/coucher (LE). Les températures codées en (temp_c + 100) & 0xFF. ⚠️ La même charge utile envoyée sur WEATHER_SET_2 (0066 0001) ne met pas à jour le widget météo sur Pro 2 — utilisez toujours 906B. (La chaîne de la ville est également un vecteur de détournement de données avéré — voir §13.)
  • FIND_WATCH (005D 0001) ✅ : payload 0x01 → la montre sonne/vibre (+ ACK 005D 0003).
  • GPS_PUSH (FFFF 906A) ✅ — big-endian, longitude en premier : 16 octets ts(u32 BE) ‖ lon×1e7(i32 BE) ‖ lat×1e7(i32 BE) ‖ 00 00. Validé sur une localisation réelle.
  • WORKOUT_GPS (FFFF A05A) ✅ — little-endian, longitude en premier : 12 octets ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32).
  • TIME (FFFF 8004) : voir §7.
  • cfbpptrames (après LZ4)usage
    42RGB565-LEfond opaque (FULL/THUMB)
    53RGB565-LE (2 o) + alpha (1 o) par pxsprites anti-crénelés (glyphes, mains, icônes)
    13 (0x0d)0,5masque alpha 4 bits ; le firmware teinte à l'exécutionatlas de glyphes de chiffres
    24 (0x18)4RGBA8888couches pleine couleur (incl. le aodImage toujours actif)
    1—JPEG/JFIF (ff d8 ff), extraire avec n'importe quel décodeurrares images d'animation
    0a
  • validate_rejects_bad_containers — rejette un corps 0x61 plat (→ NotEnvelope, le bug historique 0a) et un enfant qui dépasse sa fenêtre (→ ChildOverflow).
  • 0x0b
    :
    61 0a 00
    −18/−16
    décalages d'écriture doivent également bouger
    0a
  • Emplacements de complications multi-variantes : la métrique active n'est PAS dans le .bin. ⚠️ Une complication configurable est créée sous forme de N nœuds de groupe 0x68 empilés au même (x,y), chacun lié à une source différente (deux cercles du 275 : 0x1e/0x6a/0x48/0x24/0x19 par emplacement — ids d'option/style, pas la métrique affichée) ; les deux cercles sont byte-identiques à part leur rect + un octet d'instance (0x79/0x7a). La métrique affichée (STEPS vs KCAL vs …) est l'état RAM/config de l'appareil, donc un aperçu statique ne peut pas la reproduire à partir du fichier — au mieux une tentative.
  • Complications inactives ancrées au bord (par ex. texte bpm à (446,0), observé sur 275/302/325/365/375) sont des emplacements que le firmware ne dessine pas dans la vue par défaut — leur valeur ne peut même pas tenir avant le bord du canevas. Traiter comme caché dans l'aperçu.
  • 0x22
    aiguilles
    0x22
    aod
    0x22
    @69,209
    interface d'édition Normal|AOD isolée
  • 0x60 img_number autonome (cnt=10) — source à −5, décalé d'un vers l'avant. ✅ Même décalage d'un que §11.8 mais pour les nombres non horlogers : la date de « Gradient » se trouvait à (203,80) en haut-centre avec la source 0x17, mais le scan avant 82 a attrapé le getter d'angle du pointeur voisin (0x0a) et la position du pointeur → le nombre s'est rendu à l'emplacement du pointeur avec une source farfelue. Correction : pour un 61 0a 00 img_number dans un wrapper 0x60, faire confiance à −5/−18/−16 lorsque la source avant est impossible pour un nombre (source-0 ou un getter d'angle de pointeur 0x0a/0e/12/70/71/72) et que la position −18/−16 est valide & non nulle (la garde non nulle ignore les chiffres enfants de groupe avec relX=0).
  • 0x17 = date (jour du mois), 0x24 = température — distincts. Le cadran 340 utilise les deux (0x17 « Jun 09 » et une 0x24 température séparée), donc 0x17 est la date, pas la température. Un cadran dont la montre affiche une température dans un emplacement 0x17 est une complication configurée par l'utilisateur (état de l'appareil), pas la valeur par défaut du fichier.
  • deccartedeccarte
    0emplacement vide49–53,97–98Météo
    1–6Pas54–58Minuteur
    7–12Calories59–62Respiration
    13–18Station debout63,65–67Chronomètre
    19–24Activité modérée68–71Batterie
    25–30Fréquence cardiaque72–76Récents
    31–36SpO₂77–81Contacts
    37–42Stress82–86Cadran / téléphone
    43–48Sommeil87–91Sport (sportId ≠ 0)
    92Musique93/94/96Enregistrement d'activité / PAI / Cycle