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
328il y a 1 moisPas encore vérifié

CMF Watch Pro 2 — Protocole BLE (rétro-ingénierie)

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

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

Marqueurs de confiance

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

  • ✅ validé sur l'appareil — observé dans une capture en direct déchiffrée ou testé contre une montre réelle.
  • 🔎 issu du firmware / RE de l'APK — extrait par décompilation du firmware (1.0.0.73) ou de l'APK officielle (3.5.7) ; cohérent avec le code mais non testé en conditions réelles.
  • 🟡 partiellement prouvé — établi structurellement (hors ligne, à l'échelle du corpus, ou par RE) mais l'étape restante nécessite la montre et n'a pas été exécutée.
  • ⚠️ [incertain] — déduit, non confirmé ; peut être erroné.

Lorsqu'une section ultérieure corrige une section antérieure, le texte antérieur est conservé avec un renvoi plutôt que supprimé — savoir quelles lectures ont été essayées et réfutées évite à la personne suivante le même détour.

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, diffusant 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 tramé ci-dessous. Le canal shell (77d4…) transporte du texte brut de type AT (par ex. 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.

UUID de services — la montre diffuse ~10 services principaux. Énumérés sur une unité réelle : 0xfff0 (commande), 0x180f (batterie), 0x180a (informations sur l'appareil), 0xefe7, 0xffd0, 02f00000-…ffe0 et 02f00000-…fe00 (données), 77d4e67c-2fe2-2334-0d35-9ccd078f529c (shell / appairage), e49a3001-f69a-11e8-8eb2-f2801f1b9fd1, f48a23c0-f69a-11e8-8eb2-f2801f1b9fd1.

⚠️ L'UUID du service shell est 77d4e67c-…, et non 77d4ff00-…. Les révisions antérieures de ce document supposaient que le service partageait le préfixe ff00 de ses caractéristiques (77d4ff01/77d4ff02, §14) — ce n'est pas le cas, du moins sur l'unité sur laquelle cela a été vérifié (constat issu de freethinkel/fmc, voir §Sources). Les UUID de caractéristiques sont inchangés. Non vérifié si 77d4e67c est stable entre les unités — énumérez plutôt que de coder en dur.

🌐 Remarque Web Bluetooth. Chromium ne découvre jamais que les services que la page a répertoriés dans optionalServices, même pour un appel getPrimaryServices() non filtré — une page répertoriant 3 services en voit 3, tandis que chrome://bluetooth-internals (la couche C++ propre de Chrome, non scopée) affiche les 10. Si vous écrivez un client navigateur, listez chaque UUID ci-dessus à l'avance ou l'appairage échouera avec des services qui existent pourtant clairement. Pas de Web Bluetooth dans Firefox/Safari ; nécessite un geste utilisateur + HTTPS/localhost.

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


2. Format de trame (0xF5)

Chaque message du canal de commande est encapsulé dans une ou plusieurs trames à 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 morceaux 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 de la liaison ; le récepteur met en mémoire
  tampon les octets bruts et ré-extrait les trames complètes. Les grandes charges utiles sont divisées
  en plusieurs morceaux (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 de poids faible (`0x9055`↔`0xa055`, `0x8051`↔`0x0051`).
- `cmd1` spécifique à une fonctionnalité : suffixe `cmd2` = `0x0001` **SET**, `0x0002` **GET**, `0x0003` **ACK**.

### Corps du morceau

Pour chaque morceau, 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.

**Particularité 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 morceaux (pour que les morceaux 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 − 1`

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

---

## 3. Primitives cryptographiques

- **AES-128-CBC** avec remplissage **PKCS7** et un **IV fixe** (issu 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 sous forme de 4 octets petit-boutistes.
- **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 d'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 provenant de la montre.
  • nonce = octets issus de la réponse nonce de la montre.

Une fois la clé définie, toutes les trames du canal de commande sont chiffrées en AES à l'exception des opcodes en clair listés au §5.

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


4. Authentification / poignée de main d'appairage

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

4.1 Appairage initial (disposer du secret d'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:~
En cas de `AUTH_FAILED (0xFFFF,0xA061)` ou de non-correspondance 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 (sans 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_* tant qu'un TIME (FFFF 8004) n'a pas été envoyé dans la session — sans lui, seul un FIRMWARE_VERSION_RET non sollicité arrive et tout le reste expire. ✅ confirmé en direct (Pixel 8a) : envoi des trois GET sans TIME → seul le firmware répond ; envoi de TIME en premier → la batterie et le numéro de série commencent à répondre.

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

4.4 Schéma d'écho GET → SET (✅)

Il n'existe pas d'opcode « lecture » séparé pour la plupart des réglages. Envoyer un *_GET (cmd2 = 0x0002, payload 0xA5) fait répondre la montre avec l'opcode SET (cmd2 = 0x0001) transportant 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 de trame (cmd1/cmd2) voyagent toujours en clair, donc la séquence de commandes est visible dans toute capture même sans la clé — seuls les payloads 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 / appareil

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 réservés au JS (FFFF 8051, FFFF 0051, FFFF 90A2, FFFF 90C5, FFFF A056, FFFF 908A/908B statut/support ChatGPT) sont géré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 payloads est ⚠️ [incertaine].

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

Cadran / firmware / AGPS utilisent une boucle init → requête/écriture de morceaux → ack de fin :

(tous avec cmd1 = FFFF.) La montre pilote la boucle en émettant DATA_CHUNK_REQUEST_*(offset, length) (offset/longueur = 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

Payload de TIME (FFFF 8004) = epochSeconds(i32, BE) ‖ utcOffsetMillis(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 en UTC. L'application compagnon doit ajouter le décalage UTC local avant de dériver le jour calendaire / l'heure locale. (Regrouper la santé par jour UTC brut fait basculer le jour à la mauvaise heure locale.)

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


8. Synchronisation santé

  1. Le téléphone envoie ACTIVITY_FETCH_1 ; la montre répond ACTIVITY_FETCH_ACK_1 (premier octet 01 ⇒ prête).
  2. Le téléphone envoie ACTIVITY_FETCH_2 ; la montre pousse ensuite 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 synchronisation est séquentielle (doit suivre TIME ; la montre libère les flux après ACK_2), pas une rafale unique. Une session lourde pousse ~170–210 trames de notification en ~160 s. ✅

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

Unité de calories : les calories d'activité sont rapportées en cal (calories-grammes). Divisez la somme quotidienne par 1000 pour obtenir des 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 : timestamp(i32 LE) ‖ valeur(i32 LE) (valeur = bpm / % SpO₂ / indice de stress).
  • FC au repos (00DA 0001) est différente — 5 octets : timestamp(i32 LE) ‖ fc(u8). ✅ exemple en direct 5e dc 29 6a 4e → ts, fc = 78 bpm. Plages du score de stress : 1–29 / 30–59 / 60–79 / 80–99.

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

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

En-tête :

Chaque enregistrement de 8 octets : timestamp(u32) ‖ durée_s(u16) ‖ stade(u16). Codes de stade : 1 = Profond, 2 = Léger/léger, 3 = REM, 4 = Éveillé. ✅ validé sur une nuit complète (deux sessions, les totaux D/C/R/A se recoupent).

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

v1 : start(u32), end(u32), durée_s(u32), puis type/calories/pas/distance/FC-moyenne et un bloc GPS/étendu. ✅ la disposition v1 est 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 (exerciseLoad, aérobie/anaérobie, recoveryTime, VO₂max, cadence, PAI, meilleurs temps de course…). L'ensemble des champs est connu (à partir de la base Room de l'application) mais les décalages d'octets exacts dans ce bloc de 40 octets sont ⚠️ [incertains] — les fermer nécessite une capture brute d'un entraînement GPS.


9. Payloads de commandes sélectionnés

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

  • APP_NOTIFICATION (0065 0001) ✅ : iconCode(1) ‖ 0x00 ‖ when(u32 BE) ‖ titleLen(1) ‖ title ‖ body. iconCode 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.
  • BATTERY (005C 0001) ✅ : réponse = niveau(1) ‖ charge(1) (ex. 3b 00 = 59 %, pas en charge).
  • SERIAL_NUMBER_RET (00DE 0001) ✅ : len(1) ‖ ASCII (ex. 10 + "CI04102520008192").
  • USER_INFO (0095 0001) ✅ : taille_cm(1) ‖ poids_kg(1) ‖ âge(1) ‖ sexe(1 : 1=M) (ex. = 172 cm / 73 kg / 31 / homme).

10. Notes d'implémentation et particularités

  • Pas d'horloge système dans les codecs : les encodeurs prennent now/utc_offset comme paramètres explicites (déterministes, testables). Le transport fournit l'heure réelle.
  • TIME verrouille tout (§4.3) — envoyez-le en premier ou la montre reste muette sur les requêtes de données.
  • Le comptage CRC en texte clair (§2) est facile à mal faire — les trames en texte clair annoncent mais omettent le CRC.
  • Endianness : en-tête + opcodes BE ; entiers de payload LE ; exceptions — GOALS_SET et GPS_PUSH sont big-endian, et le décalage/longueur du transfert en masse sont big-endian.
  • MTU : les tailles de morceaux sont calculées pour que les morceaux chiffrés s'alignent sur des blocs AES de 16 octets.
  • authkey est persistable (stockez-la après le premier appairage) ; sessionKey est par connexion et dérivée du nonce de la montre à chaque reconnexion.

11. Cadrans / fonds d'écran — 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 (cadrans intégrés / du magasin : un fond plus des couches de sprites positionnées, des aiguilles, et des widgets texte). Les deux transfèrent via le canal de données par la boucle init → morceaux du §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 du magasin hors ligne ; repeindre 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é à partir 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 du magasin octet pour octet et pour émettre des conteneurs synthétiques qui passent le validateur du firmware lui-même. 🟡 la seule étape non prouvée est de regarder un rendu synthétique créé de zéro sur l'appareil via 9075 (la preuve structurelle hors ligne couvre déjà ce qui causait le rejet 0a). Il n'y a aucune barrière de codec ou de transport et aucun besoin de la chaîne d'outils du fournisseur. Les anciennes affirmations « le rendu structuré est cuit dans le RES-pack / impossible via BLE » et « codec côté serveur cf=0x1f » étaient fausses (un bug de décalage+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) ‖ selectIndex(u8) ‖ total(u8) ‖ max(u8) ‖ N × dialId(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édié « définir actif »).
  • 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 pas) — 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:~
Finish reply byte : `01` = activé et enregistré ; `0a` = stocké mais **non** activé / rejeté. Sur
Android, chaque `DATA_CHUNK_WRITE` doit partir en **un seul write BLE par frame** — concaténer puis
re-découper selon le MTU désynchronise les en-têtes et la montre boucle en demandant l'offset 0.

- **`9063` (photo) = AJOUT.** La liste des cadrans s'agrandit (6→7) ; `watchfaceId = 0xFFFFFFFF` (sentinel
  personnalisé) pour ne jamais être rejeté comme doublon, et la montre l'active automatiquement.
- **`9075` (structuré) = REMPLACE** l'emplacement `old_id`. `old_id` **doit** déjà figurer dans la liste
  (sinon `0a`). Pour réinstaller un id déjà présent, **supprimez-le d'abord** (9055 liste-moins-id)
  puis téléversez « à neuf » — réutiliser un id en place donne `0a`.

### 11.3 Cadran photo / personnalisé — ✅ entièrement validé de bout en bout

**Conteneur** (aller-retour vérifié octet 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 = bloc LZ4 standard sur RGB565 little-endian, de haut en bas (payloadLen compte à partir du premier octet LZ4). L'application officielle utilise LZ4-HC et supprime l'en-tête/pied de page de 21 octets du bloc LZ4 ; un encodeur LZ4 simple avec uniquement des littéraux fonctionne également — la montre accepte tout LZ4 valide, l'identité d'octets n'est pas requise. Les pixels en dehors du cercle inscrit (centre 233,233, rayon 233) sont définis sur 0x0000.

INIT_2 pour 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 du `.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 de mode « off ») ; `posX/posY` la positionnent (valeurs
connues 56 / 77) ; `color565` lui applique une teinte (par ex. `FFFF` = blanc). ⚠️ La forme plus courte `A5 ‖ size ‖ watchfaceId` est
**rejetée** avec le code de fin `0a` — utilisez l'en-tête complet ci-dessus. (Implémentation de référence :
`core-rust/engine.rs::build_wf_init2`, reflétant `C6135t.m31104u` dans l'application officielle.)

**Recette :** redimensionnez l'image à 466×466 (et une vignette 270×270), convertissez-la en RGB565-LE de haut en bas,
mettez éventuellement à zéro les pixels hors du cercle, compressez chaque partie en LZ4, assemblez le conteneur ci-dessus, puis téléversez
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 ✅

**Disposition du fichier** — l'en-tête de 36 octets est répété **octet pour octet comme un pied de page de 36 octets** à la fin du fichier
(✅ vérifié sur 15 cadrans ; un analyseur doit rejeter un fichier où ils diffèrent) :```
[36-byte header][scene TLV (§11.7)][asset pool][36-byte header again]

En-tête (structure identique pour les 103 cadrans de magasin ; tous les champs en little-endian) :``` 0x00 crc_tree u32 LE [CRC32-raw of header[0x04:0x24] ‖ scene section] ✅ see below 0x04 magic 01 00 00 XX [XX = 0x00 or 0x02; both seen, meaning of 0x02 unknown] 0x08 name char[16] [NUL-terminated, e.g. "SlopeTime", "Metaball"; may carry a non-zero tail after the NUL (@0x17) — round-trip it verbatim] 0x18 size_a u32 LE [= filesize − 36 = footer offset = header+body] ✅ 103 dials 0x1c size_b u32 LE [asset-pool length, exactly] ✅ 15 dials 0x20 crc_assets u32 LE [CRC32-raw of the asset pool] ✅ see below 0x24 … [body starts here: the 0x20 scene container, §11.7]

root@kitploit:~
> ⚠️ **Correction (remplace « il n'y a pas de somme de contrôle bloquante »).** Les révisions précédentes lisaient `@0x00` comme un
> identifiant/hash par dialecte et `@0x20` comme « 3× mots u32 d'identifiant/hash \[pas un CRC] », et affirmaient que CRC32/Adler32/
> la somme d'octets ne correspondaient pas. Les deux mots **sont** des CRC32 — les tests précédents les ont manqués parce que la
> variante est non standard, et parce que la lecture « 3 mots à `0x20` » confondait le mot CRC unique
> avec les premiers octets du conteneur de scène qui commence à `0x24` (de même, « nom répété à
> `0x2c` » est le nœud de nom `0x86` de la scène, §11.11). Découverte issue de
> [freethinkel/fmc](https://github.com/freethinkel/fmc) ; re-vérifiée ici.

**CRC32-raw** = polynôme IEEE réfléchi `0xEDB88320`, **`init = 0`**, et **aucun XOR final** — c'est-à-dire
ni le `init=0xFFFFFFFF` ni le `^0xFFFFFFFF` du `crc32` standard. C'est exactement la raison pour laquelle
le CRC32 standard n'a jamais correspondu. Notez la dépendance d'ordre : `crc_assets` se trouve dans la plage
couverte par `crc_tree`, donc **écrivez `@0x20` d'abord, puis calculez `@0x00`**.```python
def crc32_raw(data: bytes) -> int:          # tab = standard 0xEDB88320 reflected table
    c = 0                                    # init 0, no final inversion
    for b in data: c = tab[(c ^ b) & 0xFF] ^ (c >> 8)
    return c & 0xFFFFFFFF

crc_tree   = crc32_raw(f[0x04:0x24] + f[0x24:first_asset])
crc_assets = crc32_raw(f[first_asset:len(f)-36])

Verified : 9/9 magasins d'origine correspondent sur les deux mots, et 6/6 des modèles propres de ce dépôt correspondent sur crc_tree.

🟡 Le firmware ne semble pas appliquer l'un ou l'autre des CRC. Chaque cadran que ce dépôt a installé sur 9075 — y compris les reskins produits par le chemin de modification sur place à empreinte identique (§11.6), qui mute les charges utiles d'assets et les octets X/Y sans recalculer l'en-tête — s'est affiché correctement sur l'appareil. Donc un CRC obsolète n'est pas ce qui provoque un rejet 0a (c'est l'invariant de la fenêtre du conteneur, §11.7). Traitez les CRC comme à écrire correctement de toute façon : peu coûteux, et le seul champ d'intégrité connu du format. Tout ce qui réécrit la scène ou le pool d'assets doit recalculer les deux mots.

Cadrans stub (~173 B, p. ex. ids 273/274/277) : ce sont des espaces réservés pour des faces cuites dans la ROM : en-tête + répertoire, aucun asset réel.

Assets — 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 là est le premier jeton LZ4 — ne le sautez pas). Taille décompressée = w·h·bpp :

✅ Les 4151/4151 assets 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 ni RLE ni « échappement ». Encodage = re-raster → LZ4 standard → [dimsWord][len][LZ4].

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

root@kitploit:~
`kind` = `0x02`/`0x03` ; `old_id` = dial actif courant (depuis `9055`) ; `file_len` = taille réelle du `.bin`
(= `@0x18 + 36`). Installer un `.bin` de store 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 structurée du répertoire ✅ (décodée et implémentée — RÉVISÉ 2026-07-02)

> **⚠️ Révision (2026-07-02) : le schéma d'enregistrement plat `61 01 00` ci-dessous était
> systématiquement DÉCALÉ D'UN OCTET.** Le corps de scène est un TLV propre (§11.7) ; un **corps de feuille**
> dessinable (tags `0x30`/`0x38` statiques, `0x70` pointeur) 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² (le `s16 x,y` du
>   `sty_picture_t` du SDK).
> - la **table de trames `61 …` ferme le corps** (`base` = pointeur d'asset ; `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).
> - extras de pointeur : source+échelle `[src] 00 3c 00` dans l'attribut `0x01` ; pivot dans la
>   **remorque** `05 05 00 01 [pivX][pivY]`. **Centre de rotation = `(X+pivX, Y+pivY)` par pointeur** —
>   pas un (233,233) fixe : des sous-cadrans excentrés existent (ex. les aiguilles du dial 366 tournent
>   autour de 150,150).
>
> Le balayage linéaire pour `61 01 00` cousait la table de trames+pivot de l'élément **N** au X/Y
> (et octet de tag, 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 texte + repli non-enveloppe). Validé par l'oracle
> `wfweb/compare.html` (rendu vs PNG officiels du store, 99 cadrans) : 64→72 bons, 8→5 mauvais, diff
> 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, pivoté à l'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 getters (repli RTC 10:10:30).
- **Widget texte / nombre** (`61 0a 00`) : `asset_ptr(u32) ‖ [10×u16 métriques de police] ‖ 40 01 00 ‖
  drapeau ‖ 3B ‖ 01 ‖ u16 ‖ X(u16) ‖ Y(u16)`. `asset_ptr` pointe sur le glyphe « 0 » ; le chiffre *d* =
  l'asset à `index("0") + d` (10 sprites cf=5 consécutifs, ex. `0123456789` et ponctuation `,°`). ✅ rendu.
- **Remplissage de complication = index de trame** (✅ confirmé pour les complications chiffre/énumération/
  jauge, count>1) : la valeur indexe une **feuille de trames pré-rendues** dans le `.bin` — `frame =
  (count−1)·val/100` (pourcentage) ou `frame = value` (chiffre à bascule / énumération). Table de trames =
  sous-enregistrement `61 ‖ count(u16) ‖ base(u32) ‖ count×id(u16)`. Ex. la grande heure du 327 Digit Max
  est une feuille de 13 trames (chiffres 0–12), `frame = hour`.
- **Anneau de progression / arc = clip sectoriel à l'exécution** (✅ 2026-07-02, **corrige la lecture
  « les anneaux sont des feuilles de trames » de la spec 25 §2**) : le tag d'élément **`0x81`** porte un
  **disque complet unique** (`61` table de trames `count == 1`), et le coin partiel est ce disque **clippé
  en secteur de camembert** (`frac = value/max`, dans le sens horaire depuis 12 h) — vérifié pixel par
  pixel sur 322 Glare 2 et `count==1` confirmé sur **20 cadrans**. Sur disque : corps `0x81` = sous `0x01`
  (géométrie `x@+0 y@+2 w@+4 h@+6`, `61 1 base` inline = disque) + sous `0x5b` (spec d'arc). Implémenté
  dans wfweb (`blendSector`).
  ⚠️ Le sous-enregistrement `0x5b` n'est **pas** juste « `max` u16 `@+4` » comme documenté précédemment —
  cela lisait la moitié basse d'un `max i32` et manquait les **angles de balayage début/fin et la largeur
  de trait** qui se trouvent juste après. Il existe aussi un frère procédural `0x80`/`0x5a` (avec un rayon
  explicite) que ce document n'a jamais couvert. Disposition complète de l'enregistrement, et ce que
  l'hypothèse précédente « dans le sens horaire depuis 12 h » a mal compris : **§11.15**.
- **Id 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'id de source est un `u8` à `+0x14`** (aussi `relX@+0x07 s16`,
  `relY@+0x09 s16`, `anchor@+0x0C/0E`, `mode@+0x15`, `frame-count@+0x1A`). Anchor < 0 = aligner sur le
  bord du parent. 🔎 Le firmware résout l'id via une table de getters à 142 entrées à `0x101f371c`
  (chacune appelle `ux2sys_get(type)`).
  ⚠️ **Préférez lire l'id comme `meta[9]` de la structure (§11.11)** — un champ fixe — plutôt que ce
  balayage avant d'attribut `82`, qui est la source du décalage d'un octet décrit en §11.8/§11.9. Table
  complète des ids en §16 ; notez que les étiquettes santé/météo que cette section listait précédemment
  en ligne (`0x19` FC, `0x1b` batterie, `0x24` température, `0x36` pas) sont **contestées et probablement
  fausses** — voir l'encadré ⚠️ en §16. (L'exemple de groupe `0x07:0x0b:0x0f` = HH:MM:SS ci-dessous n'est
  pas affecté.)
- **Nœud de groupe** (`0x68`) : imbrique ses enfants dans son propre corps TLV (`0x60` = valeur/texte,
  `0x30` = statique) ; chaque `0x60` porte son id de source à `data+16`. Ex. un groupe `0x07:0x0b:0x0f` =
  horloge HH:MM:SS. Analyseur d'élément TLV = `0x100db55c` (table de sauts indexée par `tag−0x70`).

### 11.6 Matrice de création

| Chemin | Statut | Notes |
|---|---|---|
| Cadran photo depuis n'importe quelle image | ✅ **fait** | §11.3 ; validé sur appareil |
| Installer l'un des 103 cadrans du store | ✅ **fait** | §11.4 ; `9075`, `old_id`=actif |
| Reskinner le fond cf=4 d'un cadran du store | ✅ **fonctionne en direct** | échanger la charge utile COMPLÈTE en place, définir le `len` de l'asset à la **nouvelle** taille de bloc (≤ ancienne), conserver la même empreinte de fichier, réinstallation propre |
| Ré-écrire par gabarit (échanger les pixels de n'importe quelle couche + déplacer la géométrie) | ✅ **rendu via BLE** | dial 373 : fond→cyan + un sprite cf=5→rouge + X déplacé 224→100, tout rendu, aiguilles en direct |
| Cadran structuré 100 % synthétique de zéro | ✅ **builder fait, validé hors ligne** | builder d'enveloppe `0x20` dans `watchface_struct.rs` (`build_container`/`serialize`/`validate_container`) ; aller-retour sur les 103 cadrans octet-exact + synthétique passe le validateur du firmware (§11.7). 🟡 rendu sur appareil via `9075` pas encore filmé |
| Polices système (`.font`) | ✅ **décodage/rendu (toutes)** | binaire LVGL (non propriétaire) ; 32 polices de chiffres (`num*/nm*`, non compressées) + 24 polices de 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` (SINGLE/REPEATE/COUNTER à 3 états + préfiltre XOR par ligne), porté 1:1, sans désassemblage |

⚠️ Pièges de reskin/ré-écriture qui causent un écran noir ou `0a` : laisser l'**ancien `len` d'asset**
(la montre lit au-delà du bloc → dépassement → noir) ; **agrandir le fichier** (rejeté à l'installation) ;
réutiliser un id **en place** au lieu d'une installation propre ; **réordonner les assets** sans corriger
la chaîne de tailles de blocs de la queue de références (§11.11). Pas fatal aujourd'hui mais écrivez-le
correctement quand même : tout changement de la scène ou du pool d'assets invalide les deux mots **CRC32**
d'en-tête — recalculez les deux (§11.4), `@0x20` avant `@0x00`.

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

Un cadran **réel** ré-écrit rend parce qu'il préserve l'enveloppe de scène du fichier. Un corps purement
synthétique d'enregistrements plats `61 …` est **rejeté** — l'analyseur du firmware (`WFManager_Parser`,
`0xdb35c`) exige que le corps (depuis le 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 un TLV imbriqué propre — [tag u8][len u16 LE][corps], les tags conteneurs 0x20/0x21/0x22/0x68 se récursent, les feuilles dessinables 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 dessinables ; l' ancien analyseur les trouvait par heuristique — et cousait les corps adjacents ensemble, voir la révision §11.5. La disposition du corps dessinable est désormais entièrement décodée là-bas.) Le offset+len de chaque enfant doit tenir dans la fenêtre de son parent ; le premier octet du corps ≠ 0x20 → erreur d'analyse −16 ; un enfant dépassant sa fenêtre → −2 ; l'un ou l'autre fait écrire au gestionnaire 9065 (0xeb50c) la fin .

Le constructeur 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 passe sur chacun.
  • build_reframe_identity / CLI reframe — réassembler le .bin entier à 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 + dessinable imbriqué dans 20→21) qui passe l'invariant exact du firmware (build_container émet des fenêtres imbriquées correctes).

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

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

Recoupement du rendu wfweb avec les vignettes officielles du store (oracle de pixels sur les 103 cadrans) et comblé quatre lacunes :

  • X/Y des dessinables/pointeurs sont des i16 (signés). ✅ Les ancres peuvent être négatives pour les éléments qui s'étendent hors du canevas — par ex. la aiguille des secondes rouge du 275 se trouve à Y = 0xFFFC = −4 (un sprite 30×281, source 0x12, pivoté depuis le centre au-dessus 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 d'horloge numérique peuvent être des img_numbers 0x60 de niveau supérieur (pas seulement dans un groupe 0x68), et la véritable source de données est le u8 au décalage d'enregistrement −5 — le scan avant des attributs 82 est systématiquement décalé d'un ici et attrape l'attribut du frère suivant (dans le 275, le chiffre des minutes récupérait le 0x18 du jour de semaine). Le « 10:10 » du 275 = heure 0x07@X≈306 + min @X≈369 avec le comme statique adjacent entre eux, chacun un atlas de 11 glyphes (). ⚠️ En corrigeant X/Y depuis , les , sinon une ré-exportation corrompt ces octets (casse la même empreinte → ).

Aussi : les vignettes officielles du store sont rendues à 10:10 (heure marketing classique), pas 10:12 — aligner l'heure de l'oracle sur 10:10 réduit nettement la différence de pixels moyenne. L'analyseur de wfweb fait désormais un aller-retour sur les 103 cadrans octet-exact (la correction des décalages d'écriture X/Y ci-dessus a éliminé les dernières discordances).

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

  • Séparer le conteneur AOD 0x22 dans sa propre vue. ✅ Le parcours de scène saute déjà 0x22, mais le scan plat texte/numéro parcourait tout [0x30, premierAsset) — il émettait donc la variante toujours-active (AOD) de chaque élément comme couche normale. Sur « Gradient », l'atlas de date grise AOD (décalage dans 0x22) se dessinait par-dessus la rouge normale. Correctif : étiqueter chaque enregistrement 0x22 avec layer.aod=true (avec son propre ensemble de déduplication) et laisser renderAt(…, aod) les afficher uniquement en mode AOD (le mode normal masque les couches aod ; le mode AOD masque les normales ; le fond 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 surdessinaient de nombreux cadrans — et la bascule AOD de l'éditeur montre désormais la vraie disposition toujours-active au lieu des normales. La vraie AOD est un écran noir (pas de scène atténuée) : si le cadran n'a pas de cadre de fond AOD dédié (dial.aod), la scène normale est masquée en mode AOD pour qu'elle rende du noir + les éléments à leur propre couleur. Les AOD s'analysent via le parcours de scène aussi (il récurse désormais le conteneur en étiquetant les dessinables , au lieu de les laisser au scan plat où leur pivot ne correspondait pas → « non positionné ») ; les aiguilles AOD pivotent au centre du canevas (le porte parfois un x/y d'aiguille hors centre que le firmware ignore — par ex. l'heure de Gradient ). L'éditeur expose aussi cela comme une (§UI) : chaque écran ne montre que ses propres couches et les modifications persistent indépendamment. Le rendu en mode normal est octet-identique partout ; l'aller-retour reste octet-exact sur les 103 cadrans.

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

Combien de chiffres un img_number dessine est un octet unique 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 ⇒ défaut firmware 7).
  • bit 7 0x80 = remplissage de zéros (afficher les zéros de tête, par 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 bornée value % 10^NDIG et exactement NDIG glyphes sont dessinés MS en premier, les zéros de tête supprimés sauf si bit7. Le u16 après la source (60 pour la date, 1000 pour kcal) n'est PAS le compte — il alimente seulement l'insertion du glyphe séparateur de milliers/millions (cmp #1000/#1000000), c'est pourquoi le modifier ne faisait rien. L'id de source ne plafonne pas non plus.

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

Correctif / éditeur : wfweb analyse digitCount/digitZeroPad (+digitCountOff) pour les champs numériques, expose « Chiffres » + « Remplissage de zéros » dans l'inspecteur, écrit l'octet sur place (même empreinte), et l'aperçu borne/remplit à digitCount pour refléter le firmware. Donc rebondir la source d'un champ et définir son nombre de chiffres fonctionne pour n'importe quel champ (par ex. date→température °F → Chiffres 3). Oracle en mode normal inchangé (0 régressions, 3 petites améliorations) ; aller-retour octet-exact sur les 103 cadrans. (L'hypothèse antérieure « largeur de chiffres »/rectW était fausse — la largeur est uniquement la disposition, pas le compte.)

11.11 Inventaire des nœuds, l'enregistrement struct, et la queue de références de ressources ✅

La scène (§11.7) est un TLV imbriqué propre — [tag u8][len u16 LE][corps]. Inventaire complet des tags tel qu' observé sur le corpus :

✅ Cet inventaire est complet pour le corpus. En ne récursant que dans les tags conteneurs ci-dessus, le TLV de scène des 15 cadrans vérifiés parcourt exactement jusqu'à sa longueur racine déclarée avec zéro tag inconnu — donc un analyseur qui gère ce tableau gère tout le format, et un tag inconnu signifie une lecture désalignée, pas un nouveau type de nœud. (Attention : un parcours qui récurse dans chaque nœud dont la longueur se trouve être ≥ 3 descendra dans les corps struct/0x5b et hallucinera une longue queue de « tags » ponctuels — les corps de feuilles ne sont pas du TLV.)

💡 Raccourci de création : la disposition automatique 0x48/0x68 peut être entièrement sautée — chaque widget peut être placé avec un x,y absolu directement au niveau supérieur de l'écran, ce que fait le constructeur de zéro (§11.7). Nécessaire uniquement pour lire les cadrans existants. Une largeur meta de 0x8000 marque un struct comme un enfant de disposition automatique d'un cadre (la position vient du parent, pas de x,y).

Corps 0x01 struct — un préfixe fixe de 18 octets suivi d'une queue optionnelle de références de ressources :``` +0x00 x i16 [signed — can be negative, see §11.8] +0x02 y i16 +0x04 meta[14] ──────────────────────────────────────────────────── meta[0..1] w u16 [0x8000 = auto-layout child of a frame] meta[2..3] h u16 meta[4..6] unknown [placeholder-looking (1,0,0)/(4,0,0); see §11.14] meta[7] accent-tint capability flag — 4 = tintable (§11.14) meta[9] DATA SOURCE ID (§16) meta[10] sub / variant meta[11..13] max u24 LE [the metric's nominal full-scale value] +0x12 ref tail [61 …] — absent on imageless rings (0x80/0x81, §11.15)

root@kitploit:~
> ✅ Cela unifie les « décalages magiques » des §11.5/§11.8/§11.9. Ces sections localisent les champs *relativement
> à l'octet de la table de trames `0x61`* — qui est simplement `+0x12` de cette structure, donc `−18`/`−16` = `x`/`y` et
> **`−5` = `meta[9]`, l'identifiant source**. Mêmes octets, une seule disposition propre. L'heuristique d'attribut `82`
> à balayage avant qui était systématiquement décalée d'un octet n'est pas du tout nécessaire : lisez `meta[9]` de la structure.
> Le `max` d'un champ numérique est de même simplement `meta[11..13]` (par exemple, les champs de jour du mois portent `max = 99`).

**Queue de réf** (`61`) — comment un nœud pointe vers ses bitmaps :```
+0x00  0x61      [tail type]
+0x01  count u16 [1 = single image · 10/11 = digit atlas · N = pick-list / frame sheet]
+0x03  base  u32 [ABSOLUTE FILE OFFSET of the first asset block]
+0x07  count × u16  = the BLOCK SIZE (8 + payload len) of each referenced asset, in order

⚠️ Ces u16 de fin étaient auparavant documentés comme « count×id(u16) » / identifiants de glyphes. Ce sont des tailles de blocs : base, plus la somme cumulée de celles-ci, parcourt le pool d'assets entrée par entrée (✅ vérifié exact pour les 10 entrées d'un atlas de chiffres). Deux conséquences :

  • Les assets référencés par un nœud doivent être consécutifs dans le pool d'assets. Il n'y a pas d'accès aléatoire — la chaîne ne progresse que vers l'avant. Planifiez le pool pour que chaque ensemble de chiffres (10), chaque liste de sélection (N) et chaque feuille de frames forme un bloc contigu. Un rédacteur qui réordonne les assets sans corriger la chaîne produit un fichier qui se lit comme du charabia (→ écran noir).
  • Seules les count−1 premières tailles sont déterminantes ; la valeur de la dernière entrée n'est jamais suivie, donc les fichiers existants portent parfois une valeur obsolète à cet endroit. Ne traitez pas une incohérence sur l'entrée finale comme une référence cassée.

0x28 aperçu — la vignette du magasin/catalogue est intégrée dans le .bin lui-même (27 nœuds d'aperçu sur 15 cadrans), sous forme de pvStruct 0x08 : un préfixe de 5 octets plus la même queue de référence, sans x/y. Utile pour construire une interface de galerie sans livrer de PNG séparés.

11.12 Conditions de visibilité — balise 0x02 ✅

Le frère 0x02 d'un widget le rend conditionnel. Sans lui, le widget est toujours dessiné. Grammaire :``` count u8 , count × ( id u8 , op u8 , val u24 LE signed ) [5 bytes per entry]

root@kitploit:~
`id` est un identifiant de source de données (§16) — y compris les **identifiants de slot synthétiques** du §11.13. Opérateurs, avec
comptages d'occurrences mesurés sur 15 cadrans :

| op | signification | vu |
|---|---|---|
| `0x01` | dessiner si `value == val` | 99 |
| `0x81` | identique à `0x01` (bit `0x80` défini — apparaît sur les variantes mutuellement exclusives) | 48 |
| `0x02` | **masquer** si `value == val` | 7 |
| `0x03` | dessiner si `value == val`, où `val` est un **marqueur de non-données** (ex. HR `1000`) | 13 |
| `0x05` | dessiner si `value >= val` | 58 |
| `0x06` | dessiner si `value <= val` | 50 |
| `0x04` | ⚠️ **inconnu** — 15 occurrences, aucune sémantique confirmée | 15 |

Règle de combinaison (telle qu'implémentée par le moteur de rendu de référence, masque `op & 0x7f`) : les entrées d'égalité
sont combinées en **OU**, puis les entrées masquer/`>=`/`<=` doivent **toutes** être vérifiées.

Ce mécanisme unique couvre la majeure partie de la variabilité d'exécution du format et explique les structures qui ressemblent
à des widgets dupliqués :

- **Dispositions 12h / 24h et métrique / impériale** — deux ensembles de widgets empilés au même endroit, chacun lié à
  `id 0x73` (le drapeau d'unités) avec `val` 0 ou 1. Le cadran 275 possède six de ces paires (12 nœuds).
- **Espaces réservés « Pas de données »** — op `0x03` contre une sentinelle, ex. `id 0x5f, val 1000` (cadran 275, deux fois) :
  dessiner l'art du tiret cadratin au lieu d'une température lorsque la métrique est indisponible.
- **Mises en évidence par compartiments** — une plage `0x05`/`0x06` appariée, ex. la chaîne de Metaball où chaque maillon s'allume
  pour sa propre fenêtre de 5 minutes.
- **Alternatives de slots de complications** — liées aux identifiants de slot synthétiques du §11.13.

### 11.13 Slots de complications configurables — `0x85` + `0x5f` ✅ (remplace le §11.8)

Le §11.8 concluait que la métrique active d'une complication configurable est un état RAM de l'appareil et ne pouvait pas être
récupérée depuis le fichier. **C'était faux** — à la fois le menu de métriques du slot et sa sélection par défaut
se trouvent dans le `.bin`. Chaque nœud `0x85` porte un frère `0x5f` :```
+0x00  slotIndex u8   [0-based position among sibling 0x85 nodes]
+0x01  count     u8   [how many metrics this slot offers]
+0x02  activeIdx u8   [index into the list below = the DEFAULT SHOWN METRIC]
+0x03  count × u8     [the metric ids themselves (§16)]           … NUL padding

Les variantes réellement dessinées sont des groupes ordinaires 0x68 ailleurs dans l'arbre, chacun conditionné par une condition 0x02 (§11.12) sur l'id synthétique 0x79 + slotIndex — ainsi les variantes du slot 0 se lient sur 0x79, celles du slot 1 sur 0x7a, et ainsi de suite. Pour rendre un slot : lisez activeIdx, puis dessinez la variante dont la condition correspond à cet index.

Mesuré sur de vrais cadrans :

Les deux slots à 6 métriques du cadran 275 représentent 12 de ses 26 nœuds 0x02, exactement comme prévu : 01 79 81 0X 00 00 et 01 7a 81 0X 00 00 pour X = 0..5 — six variantes clés sur 0x79 (slot 0) et six sur 0x7a (slot 1). (Les octets 0x79/0x7a que §11.8 appelait un « octet d'instance » sont ces ids de liaison.) Ses 14 restants sont sans rapport : 12 sur 0x73 (l'indicateur 24h/unités métriques, val 0 ou 1 — six paires de widgets basculant entre les mises en page 12h et 24h) et 2 sur 0x5f avec op 0x03 et val = 1000, l'espace réservé « pas de données » pour la température.

Toujours un véritable état de l'appareil : quoi que l'utilisateur choisisse ensuite dans l'application compagnon, cela remplace activeIdx à l'exécution, donc un aperçu reproduit la valeur par défaut du fichier, pas nécessairement ce qu'un cadran donné affiche. imgs[0] d'un nœud 0x85 est un espace réservé « appuyer pour configurer » que le firmware dessine uniquement dans son propre mode d'édition — ignorez-le lors de l'aperçu de l'affichage normal de l'heure.

11.14 Couleur d'accent — l'indicateur de capacité meta[7] == 4 ✅

Certains cadrans permettent à l'utilisateur de choisir une couleur d'accent sur l'appareil, et le firmware la substitue dans les bitmaps du widget au moment du rendu. Le commutateur est un octet unique : meta[7] de la structure (§11.11) — c'est-à-dire l'octet +0x0B du corps 0x01 — égal à 4 marque la ou les ressources de ce widget comme teintables.

  • C'est un indicateur de capacité par widget, pas une couleur. Recolorez chaque pixel non transparent d'une ressource marquée (laissez l'alpha intact) ; aucun test de couleur par pixel n'est impliqué.
  • Prévalence : 37 des 499 structures et 8 des 15 cadrans mesurés ici ; le corpus fmc rapporte 56 des 100 cadrans avec au moins un widget marqué.
  • 🛑 N'intégrez jamais une couleur d'accent dans les octets exportés. La substitution est active sur la montre ; un .bin livré doit conserver ses pixels d'origine, sinon vous perdez définitivement le choix de l'utilisateur. Appliquez la teinte uniquement dans le chemin d'aperçu/canvas.

⚠️ N'« améliorez » pas cela en une heuristique de couleur — cette voie est une impasse avérée (documentée par fmc après l'avoir fait à la dure). La théorie intuitive est que les pixels marqués sont intégrés dans une couleur d'espace réservé reconnaissable que le firmware échange. Cela ne peut pas fonctionner : l'anneau teintable du cadran 348 Tumbler et les bandes de chiffres ordinaires non teintables des cadrans 282 Radar Sweep / 291 Vertical intègrent le exactement même RGB (255,72,32) (vérifié de manière exhaustive, chaque pixel) ; et les cadrans 305 Dots (aiguille des heures) et 306 Large Number (chiffres) sont teintables tout en étant intégrés blanc uni, donc un test de couleur les manquerait entièrement. Des raffinements successifs (1 → 4 couleurs de référence, plus une liste d'autorisation de rôle de widget) ont tous échoué. Lisez l'indicateur.

Recoupé avec l'appareil réel / l'application compagnon sur 7 cadrans, choisis pour solliciter les deux directions — 349 Theatre, 376 Digits time, 305 Dots, 306 Large Number, 304 Elaborate 2 offrent tous le réglage d'accent et ont tous des widgets meta[7]==4 ; 316 Trailing (aiguille rougeâtre, sans réglage), 312 Disc et 295 Vortex n'en offrent aucun et n'ont aucun widget marqué.

meta[4..6] se trouve juste à côté de l'indicateur et semble pouvoir encoder une couleur sur certaines structures (un RGB d'apparence réelle avec une queue f1=1,f2=255, contre l'espace réservé (1,0,0)/(4,0,0) des structures marquées). Cela ne corrèle pas avec la capacité d'accent. ⚠️ Non résolu ; ignorez-le.

11.15 Anneaux de progression — 0x80/0x5a (procédural) et 0x81/0x5b (clippé par image) ✅

Les deux variantes d'anneau associent une structure courte (x, y, meta avec l'id de source — généralement sans queue de référence du tout) à un frère de spécification d'arc. §11.5 documentait uniquement « 0x5b : max u16 @+4 », qui est la moitié basse d'un max i32 et manque la géométrie du balayage. Enregistrement complet :``` +0x00 min i32 LE [always 0 in the corpus] +0x04 max i32 LE [100 in the corpus, except dial 332 = 60] +0x08 start i16 LE [sweep start, units of 0.1° — SIGNED] +0x0a end i16 LE [sweep end, units of 0.1° — SIGNED] +0x0c width u16 LE [stroke width in px] +0x0e radius u16 LE [0x5a ONLY — 0x81 takes its radius from the clipped image] +0x0e / +0x10 trailer 01 00 kk ⚠️ unresolved (see below)

root@kitploit:~
L'appariement est rigide : `0x80` transporte toujours exactement `0x01` + un `0x5a` de **19 octets**, `0x81` toujours exactement
`0x01` + un `0x5b` de **17 octets** (✅ 26/26 anneaux sur 15 cadrans). ⚠️ Mais le **`0x81` rogné par l'image
domine** — 25 de ces 26. La variante procédurale `0x80`/`0x5a` n'est apparue **qu'une fois** (cadran 273), donc son
champ `radius` et sa disposition de 19 octets reposent sur un seul échantillon ; à traiter avec suspicion jusqu'à nouvel ordre.

`frac = clamp((valeur − min) / (max − min), 0, 1)`, et l'arc rempli va de `start` vers `end`.
L'angle zéro est à **3 heures**, positif dans le sens horaire. Exemples mesurés :

| cadran | tag | min..max | start → end | largeur | rayon |
|---|---|---|---|---|---|
| 273 Activity Mood | `0x5a` | 0..100 | **−102,8° → 102,8°** | 42 | 222 |
| 273 Activity Mood | `0x5b` | 0..100 | 270,0° → 90,0° | 80 | (image) |
| 276 Dichotomy | `0x5b` | 0..100 | 60,0° → −120,0° | 23 | (image) |
| 304 Elaborate 2 | `0x5b` | 0..100 | −2,0° → 358,0° | 24 / 80 | (image) |
| 366 Combo | `0x5b` | 0..100 | 0,0° → 270,0° | 18 | (image) |
| 368 Function | `0x5b` | 0..100 | 0,0° → 360,0° | 20 | (image) |

> ⚠️ **Ceci corrige l'hypothèse « sens horaire à partir de 12 heures »** du §11.5. Ce n'est que le cas
> particulier `start = 0, end = 3600` (un balayage complet, où la convention est inobservable). Les vrais cadrans utilisent
> des **jauges partielles** (l'éventail ±102,8° du 273, l'anneau aux trois quarts de 270° du 366) et des **balayages négatifs**
> (60° → −120° du 276), donc un moteur de rendu qui balaie toujours un cercle complet depuis le haut les dessine mal.
> 🟡 La convention exacte de l'angle zéro et la règle de direction proviennent du moteur de rendu de fmc, recoupées
> avec ces valeurs sur disque — non vérifiées indépendamment pixel par pixel sur l'appareil par ce dépôt.
> Notez que `frac = valeur/max` et le résultat du découpage de secteur `0x81` du §11.5 **ont** été validés (cadran 322).

⚠️ **Non résolu : la fin de 3 octets `01 00 kk`.** `kk` prend des valeurs plausibles (104, 152, 216,
232, 248) et l'hypothèse évidente est un rayon — **testée et réfutée** : le cadran 366 utilise `kk = 104`
pour un anneau de 82×82 et un de 166×166, et le cadran 273 utilise `kk = 232` pour un anneau de 440×440 et un de 284×284.
Pas un rayon, pas un diamètre. Peut-être opacité/style. À faire un aller-retour tel quel.

🟡 **Piège signalé, non vérifié ici :** dans le moteur de rendu de fmc, un nombre `0x60` dont l'id de source est égal à l'id de
**n'importe quel** anneau sur le même écran affiche `round(frac × 100)` au lieu de la valeur brute — donc un
nombre de fréquence cardiaque à côté d'un anneau de fréquence cardiaque affiche `36` au lieu de `71 bpm`. Leur
solution documentée est de placer l'anneau et le nombre sur deux ids **alias** de la même métrique (pas
`0x19`/`0x26`/`0x49`, calories `0x1c`/`0x1e`/`0x48`). Que ce soit un comportement du firmware ou spécifique
à leur moteur de rendu n'est **pas établi** — à vérifier en direct avant de concevoir autour.

---

## 12. Détails du transfert en masse et de l'OTA

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

- **AGPS/EPO** ✅ : le premier bloc écrit commence par l'en-tête ASCII `000000010000…`. La boucle complète
  init → `[A05F ↔ 905F]×N` → fin a été observée sur le fil (~892 blocs).
- **OTA du firmware** (`9040`–`9042`, fin `9041`) 🔎 : structure cartographiée ; charge utile INIT2 = octets de version
  (par ex. `0b 00 00 39` = 11.0.0.57). **Non testé sur le terrain** (l'application désactive la mise à jour du firmware ici). Les images
  du firmware semblent être **non signées — l'intégrité n'est que CRC32** (aucune signature asymétrique observée en 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) briquer 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, phases de sommeil, lever de poignet, cadence.
- **GNSS/GPS** (assisté AGPS) — trace d'entraînement (`WORKOUT_GPS`) et envoi de position (`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 vraie API de complications/liaison de données — voir §11.5) : les
champs de texte existants de la montre peuvent être réutilisés pour afficher des données externes en un coup d'œil. Prouvé ✅ : la
**chaîne de ville** météo (`WEATHER_SET_1`, par ex. `"BRA 2x1 ARG"` est apparue sur le widget) et les champs musique
**titre/artiste** ; la liste **contacts** (20 × nom[32]+numéro[25]) fonctionne comme un panneau de données défilant. Tout
est des envois, pas des complications persistantes.

---

## 14. Canal AT d'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'appariement de 16 octets), `GETVERSION`, `GETSN`, `GETNAME`, `GETPID`,
  `GETBATLV` (mV bruts, par ex. `3853mv`), `GETGSENSOR` (accélération brute en g, `X=… Y=… Z=…`),
  `GETNTCTEMP` (°C, NTC interne).
- **Écriture / action :** `AT SETMOTOR=1` (faire vibrer le moteur), `SETHR/SETHRV/SETSPO2=…` (injection de test
  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, hors de portée ici :

- **Voix ChatGPT** — porte = id de fonctionnalité `ux2sys` `0x9e`, initialisée depuis NVRAM/EFUSE/région au démarrage ; sur
  ce SKU, le drapeau de support `908b = 00`. Non influençable par le téléphone, le compte ou le 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 complète présente, SKU Chine uniquement.
- **Absent du matériel/firmware :** ECG, SOS/urgence, NFC générique.

---

## 16. Ids de source de données / getter de complication

Pas nécessaire pour construire un client BLE, mais **essentiel pour créer ou rendre un cadran** : c'est la valeur de
`meta[9]` (§11.11) qui lie un widget à des données en direct, l'`id` d'une condition de visibilité (§11.12), et
les entrées du menu de métriques d'un emplacement (§11.13). Le firmware le résout via une **table de dispatch de getters à 142 entrées**
à `0x101f371c`, chaque entrée appelant `ux2sys_get(type)` (🔎 rétro-ingénierie du firmware).

### Heure / date — ✅ bien établi

| id | signification | id | signification |
|---|---|---|---|
| `0x01` | heure (12/24h selon le réglage de l'appareil) | `0x0f`, `0x12` | seconde (lisse) |
| `0x04` | heure (24h) | `0x10`, `0x11` | dizaines / unités de seconde |
| `0x07` | heure (24h forcé) | `0x71`, `0x72` | seconde (tic-tac / angle d'aiguille) |
| `0x02`, `0x03` | dizaines / unités d'heure-12h | `0x13` | drapeau AM/PM (0 = AM, 1 = PM) |
| `0x05`, `0x06`, `0x08`, `0x09` | dizaines / unités d'heure | `0x15`, `0x16` | mois |
| `0x0a`, `0x70` | angle d'aiguille des heures | `0x17` | jour du mois |
| `0x0b` | minute | `0x18` | jour de la semaine (0 = lundi ⚠️) |
| `0x0c`, `0x0d` | dizaines / unités de minute | `0x0e`, `0x71` | angle d'aiguille des minutes |

Les ids de dizaines/unités dessinent un **chiffre unique** — un widget lié à l'un d'eux affiche un glyphe, pas la
valeur entière (cadran 284 Square). Les ids d'angle d'aiguille (§11.5) sont `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 getters, repli RTC 10:10:30).

### Santé / capteurs / météo — ⚠️ contesté, lisez la colonne de preuves

| id | signification (meilleure lecture actuelle) | preuves |
|---|---|---|
| `0x19` | **pas** | dans le menu d'emplacement du 368 aux côtés de `0x1a` ; correspond au `parse.ts` de ce dépôt |
| `0x1a` | **fréquence cardiaque** | dans le menu d'emplacement du 368 aux côtés de `0x5f` et `0x19` |
| `0x1c` | calories | menu d'emplacement, icône de flamme dans l'application compagnon |
| `0x1e` | calories (alias) | corpus |
| `0x22` / `0x23` | distance km / mi (partie entière) | corpus |
| `0x74` / `0x75` | distance km / mi (partie fractionnaire) | corpus |
| `0x76` | distance (forme d'emplacement) | menu d'emplacement, icône de route |
| `0x24` | **% de batterie** | menu d'emplacement, icône d'éclair |
| `0x30` | % de batterie | corpus |
| `0x36` / `0x5f` | température | `0x5f` = menu d'emplacement, icône nuage-soleil ; 361 TempoG lie un nombre simple |
| `0x48` | stations debout (heures debout) | menu d'emplacement, icône de figure debout |
| `0x8b` | IQA | menu d'emplacement |
| `0x73` | drapeau 24h / unités métriques | corpus |
| `0x25`–`0x27`, `0x49`, `0x6c`, `0x6f` | % d'objectif / alias d'emplacement des pas et calories | corpus |
| `0x6a` | ⚠️ métrique d'emplacement non identifiée | apparaît dans 4 menus d'emplacement |
| `0x79 + slotIndex` | **synthétique** — pas une métrique ; l'id de sélection d'emplacement (§11.13) | ✅ §11.13 |

> ⚠️ **Trois tables de ce dépôt étaient en désaccord ; celle-ci est la réconciliation.** Les révisions antérieures du §16
> et du §11.5 lisaient `0x19` comme fréquence cardiaque, `0x1b` comme batterie, `0x24` comme température et `0x36` comme pas —
> et `wfweb/src/codec/mock.ts` encode encore cette lecture, tandis que `wfweb/src/codec/parse.ts` encode une
> lecture différente (`0x19` pas, `0x24` % d'objectif, `0x48` stations debout, `0x1a` météo). Le tableau ci-dessus suit
> la lecture la mieux étayée (étiquettes calibrées par fmc contre les icônes du menu d'emplacement de widgets de l'application compagnon, voir §Sources). **Le code n'a pas encore été modifié — `mock.ts` et `parse.ts` sont toujours
> incohérents entre eux et avec ce tableau.** Traitez les étiquettes d'ids de santé comme ⚠️ jusqu'à ce que quelqu'un lie
> un champ à chaque id et lise la montre.
>
> La preuve unique la plus solide est **le menu d'emplacement du cadran 368 Function** (§11.13), qui propose
> `0x5f 0x1c 0x19 0x48 0x24 0x76 0x1a 0x8b` comme **huit métriques distinctes sélectionnables par l'utilisateur** dans un seul menu.
> Quelles que soient les étiquettes, deux de ces huit ne peuvent pas être la même métrique — ce qui exclut
> `0x19` = `0x1a` = fréquence cardiaque et `0x24` = `0x5f` = température simultanément.

Les complications d'anneau/arc avec une **feuille de cadres** indexent un cadre pré-rendu (par ex. 50 % = cadre 50 sur 100)
intégré dans le `.bin` que vous envoyez (§11.5), donc aucun pack RES externe n'est nécessaire ; les anneaux sans image sont dessinés
à partir de la spécification d'arc à la place (§11.15).

---

### 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 charge utile = sous-commande : `00` = GET, `01` =
SET ; **les deux utilisent `0x906D`** (`0x906C` est listé mais non utilisé — l'interroger expire). Réponse = `A06D`.

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

**Réponse GET** ✅ : `statut(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é` = 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) :

| déc | carte | déc | carte |
|----|----|----|----|
| 0 | emplacement vide | 49–53,97–98 | Météo |
| 1–6 | Pas | 54–58 | Minuteur |
| 7–12 | Calories | 59–62 | Respiration |
| 13–18 | Station debout | 63,65–67 | Chronomètre |
| 19–24 | Activité modérée | 68–71 | Batterie |
| 25–30 | Fréquence cardiaque | 72–76 | Récents |
| 31–36 | SpO₂ | 77–81 | Contacts |
| 37–42 | Stress | 82–86 | Cadran / téléphone |
| 43–48 | Sommeil | 87–91 | Sport (`sportId` ≠ 0) |
| 92 | Musique | 93/94/96 | Enregistrement d'activité / PAI / Cycle |

---

## Annexe A. Catalogue des cadrans de série (id → nom)

Le §11 fait référence aux cadrans par id numérique tout au long (275 SlopeTime, 322 Glare 2, 357 Silhouette, …). Les ids
`273`–`376` sont les faces du magasin/de série ; l'id est ce que `DIAL_COMMAND (9055/a055)` signale comme actif et
ce que `9075` prend comme `old_id` (§11.4). 100 des 103 ids connus sont nommés ci-dessous ; les restants sont des
stubs ROM (§11.4). Noms et regroupement tels qu'affichés par l'application compagnon officielle.

- **Par défaut** (6) — `273` Activity Mood · `274` Sun Circle · `275` SlopeTime · `276` Dichotomy · `277` Prismatic Time · `280` Multifunction
- **Analogique** (34) — `286` Sundial · `287` Simple Dial · `292` City · `294` Sudoku · `305` Dots · `306` Large Number · `309` Gradient · `310` Glare · `311` Bold · `313` Classical · `314` Fragment · `315` Infinite · `316` Trailing · `322` Glare 2 · `326` Chrono Master · `327` Digit Max · `328` Coherent · `329` Wheel · `330` Zenith · `331` Intersection · `335` Time Phase · `336` Energetic · `338` Chronos · `341` Dual View · `346` Time Windmill · `347` Large Panel · `349` Theatre · `352` Elegant Sweep · `360` Explorer · `364` SportPulse · `370` Hemisphere · `371` ActiveTrio · `372` Time Wheel · `373` Traditional Pointer
- **Numérique** (41) — `281` Metaball · `282` Radar Sweep · `283` Radio · `285` Widgets · `288` Type · `289` Rotate · `290` Gradual · `291` Vertical · `293` Stairs · `296` Ladder · `297` Ray · `298` Eclectic · `299` Echo · `300` Mono Dial · `301` Orbit · `302` Calendar · `303` Space · `307` Sprung · `308` Sundial 2 · `319` One Line · `320` Orienteer · `321` Revolution · `323` Dash · `324` Finesse · `325` Metric · `333` Circularity · `334` Globe of Time · `337` Time Finder · `339` Suprematism · `340` Sport Mode · `345` Time Dot · `350` Timeline · `351` Cyclopes · `353` Dual phase · `357` Silhouette · `359` Ring data · `361` TempoG · `362` Steady · `365` Elegance · `369` Solar System · `376` Digits time
- **Multifonction** (10) — `304` Elaborate 2 · `344` InfoMeter · `348` Tumbler · `354` Dual · `363` Vintage · `366` Combo · `367` Complex Figure · `368` Function · `374` Cirquary · `375` InfoHub
- **Créatif** (8) — `284` Square · `312` Disc · `317` Disc 2 · `318` Dominos · `332` Flux · `342` Perfect Match · `343` Progress Day · `358` Asteroid
- **Diwali** (1) — `295` Vortex

---

## Sources

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 contre un appareil réel. L'implémentation de référence de ce projet vit dans
`core-rust/src/{commands,frame,crypto,health,session}.rs` (Rust), l'éditeur TypeScript `wfweb/`, et
les outils Python `cmftool/` (`pair.py`, `session.py`, `wf_codec.py`, `upload_custom.py`, …).

**Travail indépendant incorporé ici.** [freethinkel/fmc](https://github.com/freethinkel/fmc) — un
éditeur de cadrans SvelteKit + marché pour la même montre — a indépendamment rétro-ingénié le
format `.bin` à partir d'un corpus d'environ 100 faces et a atteint plusieurs résultats que ce document manquait ou avait
faux. Leurs `docs/cmf-protocol.md`, `src/lib/modules/editor/lib/{wf,render}.ts` et
`src/lib/modules/device/lib/ble.ts` valent la peine d'être lus directement. Résultats adoptés, chacun re-vérifié
contre les octets de cadran avant d'être rédigé ici :

| Résultat | Où | Statut ici |
|---|---|---|
| Les deux mots d'en-tête sont CRC32 (variante non standard) | §11.4 | ✅ re-vérifié 9/9 cadrans ; **corrige** « pas de somme de contrôle bloquante » |
| Conditions de visibilité, tag `0x02` | §11.12 | ✅ re-vérifié ; était non documenté |
| Liste de métriques d'emplacement + `activeIdx` par défaut, `0x79 + slotIndex` | §11.13 | ✅ re-vérifié sur 275/368/273/304 ; **remplace** §11.8 |
| Drapeau de capacité de teinte d'accent `meta[7] == 4` | §11.14 | ✅ prévalence re-vérifiée ; était non documenté |
| Spécification complète d'arc (angles de balayage, largeur, rayon) | §11.15 | ✅ re-vérifié ; **corrige** « `max` u16 `@+4` » |
| Les `u16` de fin de réf sont des tailles de blocs, pas des ids de glyphes | §11.11 | ✅ re-vérifié exactement sur un atlas de 10 glyphes |
| Pied de page de 36 octets ; variante d'octet magique `0x02` ; `0x86` = 64 B ; aperçu intégré `0x28` | §11.4, §11.11 | ✅ re-vérifié |
| Étiquettes d'ids santé/météo calibrées contre le menu d'emplacement de l'application compagnon | §16 | ⚠️ adopté comme meilleure lecture ; conflits signalés |
| L'UUID du service shell est `77d4e67c-…`, et la portée `optionalServices` de Web Bluetooth | §1 | ⚠️ rapport mono-unité, non re-vérifié ici |
| L'id de nombre partagé avec un anneau affiche un pourcentage | §11.15 | 🟡 signalé, **non** vérifié ici |
| Catalogue id → nom des cadrans de série | Annexe A | ✅ adopté tel quel |
Télécharger l’outil
ObjectifServiceCaractéristiquePropriétés
Écriture de commande0000fff0-0000-1000-8000-00805f9b34fb0000fff2-…Write
Notification de commande0000fff0-…0000fff1-…Notify
Écriture shell (AT)—77d4ff01-2fe2-2334-0d35-9ccd078f529cWrite
Notification shell (AT)—77d4ff02-…Notify
Écriture de données en masse—02f00000-0000-0000-0000-00000000ffe1Write
Notification de données en masse—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 (liste/réorganisation/sélection)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/écritureFINISH ack1/ack2
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
04début_session (epoch, UTC)
44réveil (epoch, UTC)
82total_profond_s
102total_léger_s
122total_rem_s
142total_éveillé_s
162⚠️ [incertain] (id/score de session ? 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.
  • ALARMS_SET (0063 0001) ✅ — corrige Gadgetbridge (qui plaçait le libellé à la fin, complété par 0xff — incorrect). 40 octets par alarme, big-endian : secondesDuJour(i32) ‖ index(u8) ‖ activée(u8) ‖ masque-bits-répétition(u8) ‖ drapeau(u8) ‖ libellé[32] UTF-8. Le libellé est au décalage 8 et s'affiche sur la montre. répétition = masque de bits des jours de semaine (0 = ponctuel) ; drapeau est ⚠️ [incertain] (marqueur ponctuel ?). Exemple (13:30, idx 2) : 0000bdd8 02 01 15 00 "Alarm…".
  • GOALS_SET (005E 0001) ✅ — l'application officielle et l'implémentation de référence utilisent le DailyTargetBean v1 de 10 octets, big-endian : pas(u32 BE) ‖ distance_m(u32 BE) ‖ calories_kcal(u16 BE). (C'est la forme Gadgetbridge ; les rapports antérieurs selon lesquels la montre « ignorait » cela étaient un bug de déchiffrement de session obsolète, pas un problème de payload.) 🔎 La rétro-ingénierie du firmware montre aussi une variante étendue de 29 octets plus longue (ajoute sleep_min/exercise_min/stand_h + 6 drapeaux d'activation, tous u32 BE après un préfixe flag(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) — pas le chemin par défaut de l'application ; préférez la forme de 10 octets sauf si vous avez besoin des cibles supplémentaires.
  • STANDING_REMINDER / WATER_REMINDER (0060/0061 0001) ✅ : 11 octets : activé(1) ‖ seuil_min(u16 LE) ‖ dndStart(u32 LE) ‖ dndEnd(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 le payload.
  • SPORTS_SET (00DC 0001) ✅ : count(1) = 36 emplacements ‖ activityTypeCode[36] (codes actifs puis remplissage 00). Sélectionne quels sports apparaissent dans le menu d'entraînement de la montre.
  • HEART_MONITORING_ENABLED (009B 0001) ✅ : octet kind — 01 = FC 24/7, 02 = SpO₂, 04 = stress (mesuré toutes les 30 min).
  • HEART_MONITORING_ALERTS (FFFF 9059) ✅ : désactivé = 00 ; activé = 01 ‖ fcBas ‖ fcHaut ‖ fcHautSport ‖ spo2Bas ‖ 00 00 00 00 (une limite 0/255 = « aucune limite »).
  • FEMALE_CYCLE (FFFF 9071) ✅ : 01 ‖ predictionOpen ‖ notifySwitch ‖ cycleStartSwitch ‖ cycleStartNotifyBefore ‖ ovulationStartSwitch ‖ ovulationStartNotifyBefore ‖ fertileStartSwitch ‖ fertileStartNotifyBefore ‖ période(1) ‖ cyclePériode(1) ‖ dateDébutCycle(u32) ‖ markStart(u32) ‖ markEnd(u32) (capturé : période=5, cyclePériode=0x1c=28).
  • QUICK_REPLY (FFFF 9073) ✅ : TLV — count(1) ‖ total(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]… (7 réponses par défaut capturées et déchiffrées).
  • WORLD_CLOCK (FFFF 906F) ✅ : envoie des ID de ville numériques, pas des noms (01 ‖ count ‖ cityId(2 BE)…) ; la montre mappe les ID à partir d'une table interne. Config DST FFFF 9083 = count ‖ [id(u16 LE) ‖ dst(u16 LE) ‖ start(u32 LE) ‖ end(u32 LE)]….
  • MUSIC_INFO_SET (FFFF 905C, 131 B) ✅ : état(1 : 0=aucun/1=en pause/2=en lecture) ‖ volume(1) ‖ volumeMax(1) ‖ piste(64) ‖ artiste(64). La montre envoie aussi MUSIC_BUTTON (A05D) en retour.
  • WEATHER_SET_1 (FFFF 906B, 199 B) ✅ — utilisez celui-ci : 7×9 octets de jours + 24×2 octets d'heures + ville(32) + 7×8 octets de lever/coucher du soleil (LE). Les températures sont encodées comme (temp_c + 100) & 0xFF. ⚠️ Le même payload envoyé 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 ville est aussi un vecteur avéré de détournement de données — 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 un emplacement réel.
  • WORKOUT_GPS (FFFF A05A) ✅ — little-endian, longitude en premier : 12 octets ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32).
  • TIME (FFFF 8004) : voir §7.
  • cfbppraster (après LZ4)usage
    42RGB565-LEfond opaque (FULL/THUMB)
    53RGB565-LE (2 B) + alpha (1 B) par pxsprites anticrénelés (glyphes, aiguilles, icônes)
    13 (0x0d)0.5masque alpha 4 bits ; le firmware teinte à l'exécutionatlas de glyphes de chiffres
    24 (0x18)4RGBA8888couches pleine couleur (y compris 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 plat 0x61 (→ 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 bouger aussi
    0a
  • Emplacements de complications multi-variantes — la métrique active n'est PAS dans le .bin ⚠️ cette puce était fausse et est remplacée par la §11.13. Une complication configurable est créée comme N nœuds de groupe 0x68 empilés au même (x,y), chacun dessiné uniquement lorsqu'une condition de visibilité correspond — cela était juste. Mais la liste de métriques par emplacement (le 0x1c/0x6a/0x48/0x24/0x19/0x76 du 275) est exactement la liste de métriques, pas des « ids d'option/style » ; l'index actif par défaut est un octet dans le fichier ; et l'octet 0x79/0x7a n'est pas un « octet d'instance » mais l'id sur lequel les alternatives sont indexées (0x79 + slotIndex). Un aperçu statique peut reproduire la valeur par défaut du fichier. Voir §11.13.
  • Complications inactives ancrées au bord (par ex. texte bpm à (446,0), vu 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. Les traiter comme masquées dans l'aperçu.
  • 0x22
    aiguilles
    0x22
    aod
    0x22
    @69,209
    UI d'édition isolée Normal|AOD
  • 0x60 img_number autonome (cnt=10) — source à −5, décalage d'un vers l'avant. ✅ Même décalage d'un que §11.8 mais pour les nombres non-horloge : la date de « Gradient » se trouvait à (203,80) en haut-centre avec la source 0x17, mais le scan avant 82 attrapait le getter d'angle du pointeur voisin (0x0a) et la position du pointeur → le nombre se rendait à l'emplacement du pointeur avec une source bidon. Correctif : pour un img_number 61 0a 00 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 et non nulle (la garde non-nulle saute 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 température 0x24 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.
  • tagrôleconteneur ?corps
    0x20racine de scène (wrapper de corps, pas un dessinable)✅enfants
    0x21écran normal✅enfants
    0x22écran AOD (§11.9)✅enfants
    0x28vignette d'aperçu de catalogue intégrée✅un enfant 0x08
    0x68groupe / conteneur de disposition automatique✅cadre 0x48 + enfants
    0x30image statique, ou sélection par valeur parmi N images✅0x01 (+0x02)
    0x60lecture numérique en direct (bande de chiffres)✅0x01 + 0x40 (+0x02)
    0x70aiguille rotative✅0x01 + pivot 0x05
    0x80anneau de progression, procédural✅0x01 + 0x5a (§11.15)
    0x81anneau de progression, clippé par image✅0x01 + 0x5b (§11.15)
    0x85emplacement de complication assignable par l'utilisateur✅0x01 + 0x5f (§11.13)
    0x01struct — géométrie + attributs (ci-dessous)—x,y,meta[14] + queue de références
    0x02condition de visibilité (§11.12)—liste de conditions
    0x05pivot — flag u8, pivotX u16, pivotY u16—5 o
    0x08pvStruct — prefix[5] + queue de références, sans x/y (aperçu uniquement)——
    0x40nombre de chiffres / drapeau de remplissage de zéros (§11.10)—1 o
    0x48cadre — x,y,w,h,gap,align ligne/colonne de disposition automatique——
    0x5a / 0x5bspécification d'arc pour 0x80 / 0x81 (§11.15)—19 o / 17 o
    0x5fliste de métriques d'emplacement pour 0x85 (§11.13)——
    0x86nœud de nom d'affichage, toujours exactement 64 octets, terminé par NUL, non dessiné—64 o
    cadranslotcountactiveIdxids de métriques→ actif
    275 SlopeTime0601c 6a 48 24 19 760x1c calories
    275 SlopeTime1641c 6a 48 24 19 760x19 pas
    368 Function0805f 1c 19 48 24 76 1a 8b0x5f température
    368 Function1865f 1c 19 48 24 76 1a 8b0x1a fréquence cardiaque
    273 Activity Mood0401c 24 48 6a0x1c calories
    304 Elaborate 20/1401c 48 6a 24 / 24 1c 6a 480x1c / 0x24