Skip to content
KitploitKITPLOIT
FerramentasBlog
Enviar
FerramentasBlog
Enviar

Ferramentas de Hacking, PenTest e Cibersegurança para o seu Arsenal de Segurança!

Kitploit é um diretório de ferramentas de hacking, cibersegurança e pentesting. Descubra as últimas atualizações de projetos para encontrar vulnerabilidades, analisar sistemas, automatizar testes e fortalecer sua segurança.

··Feeds·Contato·Privacidade·© 2026 Kitploit

Diretório de Ferramentas

Categorias

Ver todas as categorias
Loading categories
CMF-Watch-Pro-2-BLE-Protocol — Protocolo BLE com engenharia reversa para o CMF Watch Pro 2, documentando layout GATT, quadros de comando criptografados AES-128-CBC, handshake de autenticação e sincronização de dados de saúde para desenvolvimento de aplicativo companheiro alternativo. | Kitploit
Ferramentas/GitHubGitHub/joshuapassos/cmf-watch-pro-2-ble-protocol
Segurança de Sistemas EmbarcadosSegurança BluetoothSegurança IoTEngenharia ReversaSegurança Sem FioCriptografiaSegurança MóvelSegurança de Hardware e IoTAnálise de Firmware

Mais Populares

Ver todos →

Descubra as ferramentas mais usadas pela nossa comunidade.

Explore todas as ferramentas

Navegue pela nossa coleção de ferramentas

Ver todas as ferramentas →
Compartilhar
GitHubjoshuapassos/cmf-watch-pro-2-ble-protocol

CMF-Watch-Pro-2-BLE-Protocol

Protocolo BLE com engenharia reversa para o CMF Watch Pro 2, documentando layout GATT, quadros de comando criptografados AES-128-CBC, handshake de autenticação e sincronização de dados de saúde para desenvolvimento de aplicativo companheiro alternativo.

Ver RepositórioSite
3há 24 diasAinda não revisado

CMF Watch Pro 2 — Protocolo BLE (engenharia reversa)

Não oficial. Este documento descreve o protocolo Bluetooth Low Energy (BLE) do CMF Watch Pro 2 (CMF by Nothing), reconstruído através de engenharia reversa para um aplicativo complementar alternativo. Não é afiliado nem endossado pela Nothing/CMF. Use por sua conta e risco.

Todos os inteiros multibyte no cabeçalho do quadro e opcodes são big-endian. Inteiros dentro de payloads de comando são little-endian, a menos que indicado o contrário (isso reflete o firmware do dispositivo) — cuidado com as exceções (GOALS_SET, GPS_PUSH, offset/length de transferência em massa são big-endian).

Marcadores de confiança

Cada afirmação não óbvia abaixo é marcada com como foi estabelecida:

  • ✅ validado no dispositivo — observado em uma captura ao vivo descriptografada ou testado em um relógio real.
  • 🔎 do firmware / APK RE — extraído por descompilação do firmware (1.0.0.73) ou do APK oficial (3.5.7); consistente com o código, mas não testado em tempo de execução.
  • ⚠️ [incerto] — inferido, não confirmado; pode estar errado.

Dispositivo de teste para todas as capturas: CMF Watch Pro 2-5485, fw 1.0.0.73, serial CI04102520008192, MCU Actions ATS3089C (Cortex-M4), tela 466×360.


1. Layout GATT

O telefone é o cliente GATT; o relógio é o periférico, anunciando como CMF Watch Pro 2-XXXX (4 caracteres hex).

Habilite notificações escrevendo 01 00 em cada CCCD (00002902-…). O canal de comando (fff1/fff2) transporta o protocolo enquadrado abaixo. O canal shell (77d4…) transporta texto simples no estilo AT (ex.: AT GETSECRET; veja §14). O canal de dados (02f0…) transporta grandes blocos binários (mostrador, firmware, AGPS), coordenados por opcodes de controle no canal de comando.

✅ Uma sessão real inteira ocorreu no único canal de comando — durante uma captura de 160 s de uso intenso houve nenhum tráfego nos canais de dados/firmware ou shell, exceto durante uma transferência explícita de OTA/mostrador.


2. Formato do quadro (0xF5)

Toda mensagem do canal de comando é encapsulada em um ou mais quadros com cabeçalho de 11 bytes:``` +------+-----------+--------+-------------+-------------+--------+-------------------+ | 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` juntos formam o **opcode** (veja §6). 🔎 confirmado com o construtor de quadros do aplicativo oficial (`C6117b.m30831g`).
- `chunkCount` = total de chunks para este comando; `chunkIndex` é **baseado em 1**.
- `chunkLen` = número de bytes de `chunk` neste quadro.
- Uma única escrita BLE pode ser fragmentada pelo MTU do link; o receptor armazena bytes brutos e reextrai quadros completos. Payloads grandes são divididos em múltiplos chunks (mesmo `cmd1/cmd2`, aumentando `chunkIndex`) e remontados em ordem.

### Convenção de opcode (✅ confirmado na transmissão)

- `cmd1 = 0xFFFF`: `cmd2` em `0x80xx`/`0x90xx` = telefone→relógio (requisição/configuração); `0x00xx`/`0xa0xx` =
  relógio→telefone (resposta). Pares combinam pelo byte baixo (`0x9055`↔`0xa055`, `0x8051`↔`0x0051`).
- `cmd1` específico de funcionalidade: sufixo `cmd2` = `0x0001` **SET**, `0x0002` **GET**, `0x0003` **ACK**.

### Corpo do chunk

Para cada chunk, o corpo é `payloadPiece ‖ CRC32_LE(payloadPiece)` (CRC de 4 bytes, little-endian,
zlib/IEEE). Se o comando for **criptografado** (veja §3), todo o `payloadPiece ‖ CRC` é então
criptografado com AES-128-CBC/PKCS7 e esse texto cifrado se torna o `chunk` do quadro.

**Excentricidade do texto simples:** para opcodes em texto simples, o relógio *considera* o CRC de 4 bytes em `chunkLen`, mas **não** o transmite. Então, ao decodificar um quadro de texto simples, o comprimento real dos dados é `chunkLen − 4`.
(Quadros criptografados carregam o CRC dentro do texto cifrado como normal.)

Dimensionamento de chunks (para que chunks criptografados fiquem nos limites de bloco AES), com `maxWrite = mtu − 3`:
- criptografado: `floor((maxWrite − 11) / 16) * 16 − 4 − 1`
- texto simples: `maxWrite − 11 − 4 − 1`

✅ Todos os valores de `chunkLen` de quadros criptografados observados eram múltiplos de 16 (alinhamento de bloco válido).

---

## 3. Primitivas criptográficas

- **AES-128-CBC** com preenchimento **PKCS7** e um **IV fixo** (do firmware
  `CmfCharacteristic.AES_IV`):
  `50 51 52 53 54 55 56 57 60 61 62 63 64 65 66 5A`.
- **CRC32** (zlib/IEEE), emitido como 4 bytes little-endian.
- **SHA-256** sobre a concatenação das partes.

Derivação de chave:```
authkey      = SHA256( rnd1 ‖ rnd2 ‖ secret )[0..16]      // persisted across sessions
sessionKey   = SHA256( nonce ‖ authkey )[0..16]           // per connection
  • secret = segredo do dispositivo de 16 bytes (obtido do relógio através do comando shell AT GETSECRET → GETSECRET:<32-hex>,OK).
  • rnd1 = 16 bytes aleatórios escolhidos pelo telefone; rnd2 = 16 bytes aleatórios do relógio.
  • nonce = bytes da resposta nonce do relógio.

Após a chave ser definida, todos os quadros do canal de comando são criptografados com AES exceto os opcodes de texto simples listados em §5.

✅ Ambas as derivações validadas: authkey recuperado de um telefone com root ntwatch.db correspondeu ao valor derivado de um rnd1/rnd2/secret capturado; sessionKey reproduzido de um nonce capturado descriptografa quadros ao vivo.


4. Autenticação / handshake de pareamento

Dois caminhos de entrada compartilham a mesma cauda nonce/confirm.

4.1 Pareamento pela primeira vez (ter o segredo do dispositivo)```

phone → (shell) AT GETSECRET watch → (shell) GETSECRET:<32hex>,OK phone: rnd1 = random16 ; signed1 = SHA256(rnd1 ‖ secret) phone → AUTH_PAIR_REQUEST (plaintext) payload = rnd1(16) ‖ signed1(32) // 48 B watch → AUTH_PAIR_REPLY (plaintext) payload = rnd2(16) ‖ signed2(32) // 48 B phone verifies signed2 == SHA256(rnd2 ‖ secret) phone: authkey = SHA256(rnd1 ‖ rnd2 ‖ secret)[0..16] → set crypto key = authkey phone → AUTH_PHONE_NAME (encrypted) payload = 0xA5 ‖ model(UTF-8) // e.g. "CMF Watch Pro 2" watch → AUTH_WATCH_MAC (encrypted) phone → AUTH_NONCE_REQUEST (encrypted) payload = 0xA5 watch → AUTH_NONCE_REPLY (encrypted) payload = nonce phone: sessionKey = SHA256(nonce ‖ authkey)[0..16] → set crypto key = sessionKey phone → AUTHENTICATED_CONFIRM_REQUEST (encrypted) payload = 0xA5 watch → AUTHENTICATED_CONFIRM_REPLY (encrypted) → state = Initialized

root@kitploit:~
Em `AUTH_FAILED (0xFFFF,0xA061)` ou incompatibilidade de assinatura, a autenticação falha.

### 4.2 Reconectar (authkey já conhecido)```
        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

✅ A ordem de reconexão (sem tráfego de shell) foi observada intacta em uma captura real.

4.3 Inicialização pós-autenticação (fase 2)

⚠️→✅ TIME é obrigatório antes de consultas de dados. Após Initialized, o relógio não responderá a BATTERY, SERIAL_NUMBER_GET ou ao handshake ACTIVITY_FETCH_* até que um TIME (FFFF 8004) tenha sido enviado na sessão — sem ele, apenas um FIRMWARE_VERSION_RET não solicitado chega e tudo o mais expira. ✅ confirmado ao vivo (Pixel 8a): enviar os três GETs sem TIME → apenas firmware responde; enviar TIME primeiro → bateria e serial começam a responder.

Ordem recomendada para a fase 2: TIME → FIRMWARE_VERSION_GET → SERIAL_NUMBER_GET → BATTERY (0xA5) → envios de configuração → sincronização de saúde (§8).

4.4 Padrão GET → SET echo (✅)

Não há um opcode separado de "leitura" para a maioria das configurações. Enviar um *_GET (cmd2 = 0x0002, payload 0xA5) faz o relógio responder com o opcode SET (cmd2 = 0x0001) carregando o valor atual. Comandos SET são confirmados com cmd2 = 0x0003 e corpo vazio.


5. Texto plano vs criptografado

Os frames são criptografados com AES assim que uma chave é definida, exceto estes opcodes, que sempre estão em texto plano:

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

Os cabeçalhos dos frames (cmd1/cmd2) sempre viajam em claro, então a sequência de comandos é visível em qualquer captura mesmo sem a chave — apenas os payloads criptografados precisam de sessionKey.


6. Referência de opcodes (cmd1, cmd2)

GET/SET/REQUEST = telefone→relógio; RET/REPLY/ACK/RESPONSE/DATA = relógio→telefone.

Sessão / dispositivo

Autenticação

Notificações / chamada / encontrar

Música

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

Alarmes / contatos / lembretes

Configuração

Clima

Nomecmd1,cmd2
WEATHER_SET_1 (o que funciona)FFFF 906B
WEATHER_SET_2 (ignorado no Pro 2 — veja §9)0066 0001

Mostradores / mostradores de relógio

Saúde / sincronização

Opcodes apenas JS (FFFF 8051, FFFF 0051, FFFF 90A2, FFFF 90C5, FFFF A056, FFFF 908A/908B status/suporte ChatGPT) são tratados no bytecode Hermes do aplicativo, não na camada Java. Seus cabeçalhos aparecem em capturas, mas a semântica do payload é ⚠️ [incerta].

Transferência de dados em massa (canal de dados)

Mostrador de relógio / firmware / AGPS usam um loop de init → solicitação-chunk/escrita-chunk → finish-ack:

(todos cmd1 = FFFF.) O relógio conduz o loop emitindo DATA_CHUNK_REQUEST_*(offset, length) (offset/length = u32 big-endian); o telefone responde com DATA_CHUNK_WRITE_* carregando payload[offset..offset+length] na característica de dados. Veja §11–§12 para detalhes.


7. Hora e fuso horário

Payload TIME (FFFF 8004) = epochSeconds(i32, BE) ‖ utcOffsetMillis(i32, BE). Enviado logo após a autenticação para que o relógio mostre a hora local (e desbloqueie consultas de dados — veja §4.3).

⚠️ Os timestamps de saúde do relógio são UTC. O aplicativo complementar deve adicionar o offset UTC local antes de derivar o dia calendário local / hora do dia. (Agrupar saúde pelo dia UTC bruto faz o dia mudar na hora local errada.)

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


8. Sincronização de saúde

  1. O telefone envia ACTIVITY_FETCH_1; o relógio responde ACTIVITY_FETCH_ACK_1 (primeiro byte 01 ⇒ pronto).
  2. O telefone envia ACTIVITY_FETCH_2; então o relógio envia uma rajada de frames de dados: ACTIVITY_DATA, HEART_RATE_*, SPO2, STRESS, SLEEP_DATA, WORKOUT_SUMMARY[_V3].
  3. Cada um é analisado em amostras por minuto / sessões e agregado por dia local.

A sincronização é sequencial (deve seguir TIME; o relógio libera os streams após ACK_2), não uma rajada única. Uma sessão pesada envia ~170–210 frames de notificação em ~160 s. ✅

8.1 Registro de atividade — ACTIVITY_DATA (32 bytes cada, LE) ✅

Unidade de caloria: as calorias de atividade são relatadas em cal (calorias-grama). Divida a soma diária por 1000 para obter kcal. (Calorias de resumo de treino, por outro lado, já estão em kcal.)

8.2 Amostras de FC / SpO₂ / Estresse ✅

  • FC manual/automática, FC de treino, SpO₂, estresse = 8 bytes cada: timestamp(i32 LE) ‖ value(i32 LE) (valor = bpm / % SpO₂ / índice de estresse).
  • FC de repouso (00DA 0001) é diferente — 5 bytes: timestamp(i32 LE) ‖ hr(u8). ✅ exemplo ao vivo 5e dc 29 6a 4e → ts, hr = 78 bpm. Faixa de pontuação de estresse: 1–29 / 30–59 / 60–79 / 80–99.

8.3 Sono — SLEEP_DATA (cabeçalho de 18 bytes + N × 8 bytes de registros) ✅

Um SLEEP_DATA = uma sessão de sono; uma noite pode conter várias (micro-despertares dividem as sessões).

Cabeçalho:

Cada registro de 8 bytes: timestamp(u32) ‖ duration_s(u16) ‖ stage(u16). Códigos de estágio: 1 = Profundo, 2 = Principal/leve, 3 = REM, 4 = Acordado. ✅ validado contra uma noite completa (duas sessões, totais D/C/R/A reconciliam).

8.4 Resumo de treino — WORKOUT_SUMMARY v1 (54 bytes) / _V3 (0160 0001)

v1: start(u32), end(u32), duration_s(u32), depois tipo/calorias/passos/distância/FC-média e um bloco GPS/extendido. ✅ layout v1 confirmado contra firmware. WORKOUT_SUMMARY_V3 é um layout mais novo para os mesmos dados mais um bloco estendido de ~40 bytes (exerciseLoad, aeróbico/anaeróbico, recoveryTime, VO₂max, cadência, PAI, melhores tempos de corrida…). O conjunto de campos é conhecido (do banco de dados Room do aplicativo), mas os offsets exatos dos bytes dentro desse bloco de 40 bytes são ⚠️ [incertos] — fechá-los precisa de uma captura bruta de um treino com GPS.


9. Payloads de comandos selecionados

Strings são UTF-8, truncadas em bytes até o tamanho do campo (o truncamento pode dividir um caractere multi-byte, igual ao comportamento s.encode()[:max] do firmware); campos curtos são preenchidos com zeros à direita.

  • APP_NOTIFICATION (0065 0001) ✅: iconCode(1) ‖ 0x00 ‖ when(u32 BE) ‖ titleLen(1) ‖ title ‖ body. iconCode seleciona o ícone do aplicativo (WhatsApp=8, Telegram=12, Instagram=18, Gmail=27; desconhecido=0xFF). Title ≤ 20 bytes, body ≤ 128 bytes. Enviado de um cliente → relógio exibiu + ACK 0065 0003.
  • BATTERY (005C 0001) ✅: resposta = level(1) ‖ charging(1) (ex.: 3b 00 = 59 %, não carregando).
  • SERIAL_NUMBER_RET (00DE 0001) ✅: len(1) ‖ ASCII (ex.: 10 + "CI04102520008192").
  • USER_INFO (0095 0001) ✅: height_cm(1) ‖ weight_kg(1) ‖ age(1) ‖ gender(1: 1=M) (ex.: = 172 cm / 73 kg / 31 / masculino).

10. Notas de implementação e peculiaridades

  • Sem relógio do sistema nos codecs: os codificadores recebem now/utc_offset como parâmetros explícitos (determinístico, testável). O transporte fornece a hora real.
  • TIME é a chave para tudo (§4.3) — envie-o primeiro ou o relógio fica mudo em consultas de dados.
  • Contagem de CRC em texto plano (§2) é fácil de errar — frames em texto plano anunciam mas omitem o CRC.
  • Endianness: cabeçalho + opcodes BE; inteiros de payload LE; exceções — GOALS_SET e GPS_PUSH são big-endian, e offset/length de transferência em massa são big-endian.
  • MTU: tamanhos de chunk são calculados para que chunks criptografados se alinhem em blocos AES de 16 bytes.
  • authkey é persistível (armazene após o primeiro pareamento); sessionKey é por conexão e derivada do nonce do relógio a cada reconexão.

11. Mostradores / mostradores de relógio — criação

O relógio suporta (a) mostradores de foto/personalizados (uma imagem de fundo + um relógio digital desenhado pelo firmware) e (b) mostradores estruturados (mostradores internos / da loja: um fundo mais camadas de sprites posicionados, ponteiros e widgets de texto). Ambos transferem pelo canal de dados via loop init → chunk em §6.

O que realmente funciona (✅ validado ao vivo): construir um mostrador de foto a partir de qualquer imagem e instalá-lo; instalar qualquer um dos 103 mostradores da loja offline; revestir um mostrador estruturado (trocar o fundo ou qualquer sprite não-fundo) e mover suas camadas; reordenar / alternar o mostrador ativo; e construir um mostrador estruturado do zero — o envelope de cena 0x20 é decodificado e o construtor está implementado (§11.7), comprovado offline para fazer round-trip de todos os 103 mostradores da loja byte por byte e para emitir contêineres sintéticos que passam o próprio validador do firmware. 🟡 o único passo não comprovado é ver um render sintético do zero no dispositivo via 9075 (a prova offline estrutural já cobre o que costumava causar a rejeição 0a). Não há barreira de codec ou transporte e nenhuma necessidade da toolchain do fornecedor. As antigas alegações de "render estruturado é assado em RES-pack / impossível via BLE" e "codec do lado do servidor cf=0x1f" estavam erradas (um bug de offset+bytes-per-pixel) — o firmware renderiza mostradores estruturados orientados a dados a partir do arquivo que você envia.

11.1 Gerenciamento de mostrador — DIAL_COMMAND (9055 / a055) ✅

  • tipo 0 = consultar a lista. Resposta a055 = result(u8) ‖ selectIndex(u8) ‖ total(u8) ‖ max(u8) ‖ N × dialId(u32 LE) ‖ ffffffff. Exemplo: 01 05 06 07 … = ativo #5, 6 mostradores, máximo 7.
  • tipo 1 = reordenar / selecionar ativo: reenviar a lista inteira com o mostrador alvo no índice 0 (é assim que o aplicativo oficial troca de mostradores; não há um opcode dedicado "definir ativo").
  • Excluir um mostrador = reenviar a lista sem seu id.
  • CHANGE_DIAL (009F 0001) é inerte no fw 1.0.0.73 (retorna uma constante, não troca) — não o use.

11.2 Fluxo de transferência ✅```

INIT1 8052 (payload A5) → 0052 [0]=01 INIT2 9063 (photo, APPEND) | 9075 (structured, REPLACE) → A063 / A075 [0]=01 [ watch → DATA_CHUNK_REQUEST A064 (offset, length; u32 BE, +progress u8) phone → DATA_CHUNK_WRITE 9064 (bytes[offset..offset+length], plaintext) ] × N FINISH A065 → 9065 (payload A5)

root@kitploit:~
Byte de resposta final: `01` = ativado e salvo; `0a` = armazenado mas **não** ativado/rejeitado. No
Android, cada `DATA_CHUNK_WRITE` deve ser enviado como **um BLE write por quadro** — concatenar e
re-cortar por MTU dessincroniza os cabeçalhos e os loops do relógio solicitando offset 0.

- **`9063` (foto) = APPEND.** A lista de mostradores cresce (6→7); `watchfaceId = 0xFFFFFFFF` (sentinel
  personalizado) para que nunca seja rejeitado como duplicata, e o relógio o ativa automaticamente.
- **`9075` (estruturado) = REPLACE** o slot do `old_id`. O `old_id` **deve** já estar na lista
  (caso contrário `0a`). Para reinstalar um id já presente, **delete-o primeiro** (9055 lista-menos-id)
  então carregue "novo" — reutilizar um id no lugar dá `0a`.

### 11.3 Mostrador personalizado / dial de foto — ✅ totalmente validado ponta a ponta

**Container** (ida e volta verificado por byte; todos os campos em 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 = bloco LZ4 padrão sobre RGB565 little-endian, top-down (payloadLen conta a partir do primeiro byte LZ4). O aplicativo oficial usa LZ4-HC e remove o cabeçalho/rodapé de 21 bytes do bloco LZ4; um codificador LZ4 simples apenas com literais também funciona — o relógio aceita qualquer LZ4 válido, a identidade de bytes não é necessária. Pixels fora do círculo inscrito (centro 233,233, raio 233) são definidos como 0x0000.

INIT_2 para 9063 — cabeçalho exato (✅ este é o que funciona):``` 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` = comprimento exato do `.bin`; `FFFFFFFF` = `watchfaceId` personalizado; `styleId` 0–4 seleciona o layout do relógio digital embutido (é sempre desenhado — não há "desligado"); `posX/posY` posicionam-no (56 / 77 conhecidos como bons); `color565` o tinge (ex.: `FFFF` = branco). ⚠️ A forma mais curta `A5 ‖ size ‖ watchfaceId` é **rejeitada** com finalização `0a` — use o cabeçalho completo acima. (Implementação de referência: `core-rust/engine.rs::build_wf_init2`, espelhando `C6135t.m31104u` no aplicativo oficial.)

**Receita:** redimensionar a imagem para 466×466 (e uma miniatura de 270×270), converter para RGB565-LE de cima para baixo, opcionalmente zerar os pixels fora do círculo, comprimir cada um com LZ4, montar o contêiner acima e enviar via o pipeline `9063` com `watchfaceId = 0xFFFFFFFF`. (Codec de referência: `core-rust/watchface.rs`, `work/codec_dfa.py`.)

### 11.4 Mostrador estruturado / de loja — contêiner e codecs ✅

**Cabeçalho** (idêntico em todos os 103 mostradores de loja; todos os campos 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

Não há checksum de bloqueio (CRC32/Adler32/byte-sum falham todos) — o reempacotamento não é bloqueado. Mostradores fictícios (~173 B, ex. ids 273/274/277) são placeholders para faces gravadas na ROM: cabeçalho + diretório, sem assets reais.

Assets — cada um é dimsWord(u32 LE) ‖ len(u32 LE) ‖ LZ4(payload), onde cf = dimsWord & 0x1f, w = (dimsWord >> 10) & 0x7FF, h = (dimsWord >> 21) & 0x7FF, e len conta a partir do primeiro byte LZ4 (o 1f 00 01 00 que você vê frequentemente é o primeiro token LZ4 — não pule). Tamanho descomprimido = w·h·bpp:

✅ Todos os 4151/4151 assets nos 103 mostradores descodificam exatamente com um descompressor lz4.block padrão em w·h·bpp. Transparência é o byte alpha (cf=5/24) ou 0x0000 (cf=4 fora do círculo) — não há RLE nem "escape". Codificação = re-raster → LZ4 padrão → [dimsWord][len][LZ4].

INIT_2 para 9075 — corpo encriptado com AES:``` kind(1) ‖ old_id(u32 LE) ‖ new_id(u32 LE) ‖ file_len(u32 LE)

root@kitploit:~
`kind` = `0x02`/`0x03`; `old_id` = mostrador ativo atual (de `9055`); `file_len` = tamanho real do `.bin` (= `@0x18 + 36`). Instalar um `.bin` da loja como está é o caminho garantido (Ring Data id 359 + 102 outros confirmados). (Referência: `core-rust/engine.rs::build_dial_replace_init`.)

### 11.5 Gramática de diretório estruturada ✅ (decodificada e implementada — REVISADO 2026-07-02)

> **⚠️ Revisão (2026-07-02): o esquema de registro plano `61 01 00` abaixo estava sistematicamente OFF-BY-ONE.** O corpo da cena é um TLV limpo (§11.7); um **corpo de folha** desenhável (tags `0x30`/`0x38` estáticas, ponteiro `0x70`) é:
>
> ```
> 01 xx 00 [X u16][Y u16] …attrs… 61 [count u16][base u32][count×id u16] [05 05 00 01 pivX pivY]
> ```
>
> - attr `0x01` abre o corpo: **X,Y = canto superior esquerdo** na tela 466² (o `s16 x,y` do `sty_picture_t` do SDK).
> - a **tabela de quadros `61 …` fecha o corpo** (`base` = ponteiro de asset; `count` 1 = imagem, 10/11 = atlas de dígitos — o antigo "tipo de registro `0a/0b`" era na verdade esta contagem! — 7/13/2 = folha de quadros de complicação).
> - extras do ponteiro: origem+escala `[src] 00 3c 00` dentro do attr `0x01`; pivô no **trailer** `05 05 00 01 [pivX][pivY]`. **Centro de rotação = `(X+pivX, Y+pivY)` por ponteiro** — não fixo (233,233): existem submostradores descentralizados (por exemplo, os ponteiros do mostrador 366 giram em torno de 150,150).
>
> A varredura linear por `61 01 00` estava costurando a tabela de quadros+pivô do elemento **N** ao X/Y (e byte de tag, o antigo "f3") do elemento **N+1** — só parecia certo em mostradores analógicos cujos ponteiros adjacentes compartilham geometria quase idêntica. A "parede de variante compacta" (especificação 24 §24.4.5) era esta mesma leitura incorreta. Implementado como `scan_scene_drawables` em `core-rust/watchface_struct.rs` e `wfweb/src/codec/parse.ts` (cena = fonte primária para imagens/ponteiros; varredura plana mantida para texto + fallback não envelope). Validado pelo oráculo `wfweb/compare.html` (render vs PNGs oficiais da loja, 99 mostradores): 64→72 bons, 8→5 ruins, diferença média 9,3→7,4%.

Leitura histórica de registro plano (substituída, mantida para contexto):

- **Imagem estática** (`61 01 00`): `asset_ptr(u32) ‖ elemId(u16) ‖ 05 05 00 01 ‖ pivotX(u16) ‖ pivotY(u16) ‖ 3B ‖ 01 ‖ 1b 00 ‖ X(u16) ‖ Y(u16)`. Canto superior esquerdo na tela 466² = `(X−pivotX, Y−pivotY)`.
- **Ponteiro/mão** — mesmo registro de imagem, rotacionado em tempo de execução. **Centro de rotação = `(X+pivotX, Y+pivotY)`** (≈ 233,233 em mostradores analógicos). A **fonte de dados é um `u8` no offset do registro `+36`**, escala `u16` em `+38` (=60): `0x0a`/`0x70` = hora (`h·30°+m·0.5°`), `0x0e`/`0x71` = minuto (`m·6°+s·0.1°`), `0x12`/`0x72` = segundo (`s·6°`). ✅ confirmado pela desmontagem dos getters (fallback RTC 10:10:30).
- **Widget de texto/número** (`61 0a 00`): `asset_ptr(u32) ‖ [10×u16 font metrics] ‖ 40 01 00 ‖ flag ‖ 3B ‖ 01 ‖ u16 ‖ X(u16) ‖ Y(u16)`. `asset_ptr` aponta para o glifo "0"; dígito *d* = o asset em `index("0") + d` (10 sprites consecutivos cf=5, por exemplo, `0123456789` e pontuação `,°`). ✅ renderizado.
- **Preenchimento de complicação = índice de quadro** (✅ confirmado para complicações de dígito/enum/medidor, contagem>1): o valor indexa uma **folha de quadros pré-renderizada** no `.bin` — `quadro = (contagem−1)·val/100` (percentual) ou `quadro = valor` (dígito de alternância/enum). Tabela de quadros = sub-registro `61 ‖ contagem(u16) ‖ base(u32) ‖ contagem×id(u16)`. Por exemplo, o grande dígito do 327 Digit Max é uma folha de 13 quadros (números 0–12), `quadro = hora`.
- **Anel de progresso / arco = clipe de setor em tempo de execução** (✅ 2026-07-02, **corrige a leitura "anéis são folhas de quadros" na especificação 25 §2**): o elemento tag **`0x81`** carrega um **único** disco completo (`61` tabela de quadros `contagem == 1`), e a cunha parcial é aquele disco **recortado em um setor de pizza** (`frac = valor/máximo`, sentido horário a partir das 12 horas) — verificado pixel a pixel no 322 Glare 2 e confirmado `contagem==1` em **20 mostradores**. No disco: corpo `0x81` = sub `0x01` (geometria `x@+0 y@+2 w@+4 h@+6`, inline `61 1 base` = disco) + sub `0x5b` (`máx` u16 `@+4`, =100 exceto 332=60). Implementado no wfweb (`blendSector`).
- **Id da fonte de dados** — o bloco de atributos `82` do elemento está em `delim+3` (após o último `40 01 00`), e o **id da fonte é um `u8` em `+0x14`** (também `relX@+0x07 s16`, `relY@+0x09 s16`, `ancora@+0x0C/0E`, `modo@+0x15`, `contagem-de-quadros@+0x1A`). Âncora < 0 = alinhar à borda do pai. 🔎 O firmware resolve o id através de uma tabela de getters de 142 entradas em `0x101f371c` (cada uma chama `ux2sys_get(tipo)`). Ids comuns (§16): `0x07` hora, `0x0b` minuto, `0x0f` segundo, `0x16` mês, `0x18` dia da semana, `0x13` AM/PM, `0x19` HR, `0x1b` bateria %, `0x24` temperatura, `0x36` passos, `0x70/71/72` ângulos das mãos, `0x25–27` meta %. (Isso corresponde ao grupo de exemplo `0x07:0x0b:0x0f` = HH:MM:SS abaixo.)
- **Nó de grupo** (`0x68`): aninha seus filhos dentro de seu próprio corpo TLV (`0x60` = valor/texto, `0x30` = estático); cada `0x60` carrega seu id de fonte em `data+16`. Por exemplo, um grupo `0x07:0x0b:0x0f` = relógio HH:MM:SS. Analisador de elemento TLV = `0x100db55c` (tabela de salto indexada por `tag−0x70`).

### 11.6 Matriz de autoria

| Caminho | Status | Notas |
|---|---|---|
| Mostrador de foto a partir de qualquer imagem | ✅ **concluído** | §11.3; validado no dispositivo |
| Instalar qualquer um dos 103 mostradores da loja | ✅ **concluído** | §11.4; `9075`, `old_id`=ativo |
| Recolorir fundo cf=4 de um mostrador da loja | ✅ **funciona ao vivo** | trocar o payload COMPLETO no lugar, definir o `len` do asset para o **novo** tamanho de bloco (≤ antigo), manter a mesma pegada de arquivo, instalação limpa |
| Re-autoria por template (trocar pixels de qualquer camada + mover geometria) | ✅ **renderiza via BLE** | mostrador 373: fundo→ciano + um sprite cf=5→vermelho + movimento X 224→100, tudo renderizado, mãos ao vivo |
| Mostrador estruturado 100% sintético do zero | ✅ **construtor concluído, validado offline** | construtor de envelope `0x20` em `watchface_struct.rs` (`build_container`/`serialize`/`validate_container`); ida e volta em todos os 103 mostradores byte-exato + sintético passa validador do firmware (§11.7). 🟡 renderização no dispositivo sobre `9075` ainda não filmada |
| Fontes do sistema (`.font`) | ✅ **decodificar/renderizar (todas)** | LVGL bin (não proprietário); 32 fontes numéricas (`num*/nm*`, descomprimidas) + 24 fontes de texto (`font*`, LVGL RLE `comp=1`) todas decodificadas — 12208 glifos, 0 estouros, ASCII completo. RLE = LVGL v8.3 `lv_font_fmt_txt.c` (3 estados SINGLE/REPEATE/COUNTER + pré-filtro XOR por linha), portado 1:1, sem desmontagem |

⚠️ Armadilhas de recolorir/re-autoria que causam tela preta ou `0a`: deixar o **`len` do asset antigo** (o relógio lê além do bloco → estouro → preto); **aumentar o arquivo** (rejeitado na instalação); reutilizar um id **no lugar** em vez de uma instalação limpa.

### 11.7 O envelope de cena `0x20` — decodificado e construtor implementado ✅

Um mostrador **real** re-escrito renderiza porque preserva o envelope de cena do arquivo. Um corpo puramente sintético de registros planos `61 …` é **rejeitado** — o analisador do firmware (`WFManager_Parser`, `0xdb35c`) exige que o corpo (a partir do offset `0x24`) comece com um container de cena `0x20`. O arquivo completo é:```
[0x00,0x24)  header:  perDialId@0 · version=1@4 · name[16]@8 · size_a@0x18 · size_b@0x1c · idWord0@0x20
[0x24, fa)   scene:   20 <u16 L0> ( 21 <u16 L1> ( 86 <len>=name , 30/70/80/81… drawables ) [ 22 … AOD ] )
[fa, EOF)    assets:  [dimsWord u32][len u32][payload = 1f 00 01 00 + LZ4] …
   size_a = filesize−36 · size_b = filesize−36−first_asset · 0x27+L0 == first_asset

A cena é uma TLV aninhada limpa — [tag u8][len u16 LE][body], tags de contêiner 0x20/0x21/0x22/0x68 recursivas, folhas desenháveis 0x30 (estático) / 0x70 (elemento/ponteiro) / 0x80 / 0x81 / 0x86 (nome). (Os registros planos 61 01 00 / 61 0a 00 são padrões que vivem dentro dos corpos desenháveis; o antigo analisador os encontrava heuristicamente — e costurava corpos adjacentes, veja a revisão §11.5. O layout do corpo desenhável agora está totalmente decodificado ali.) O offset+len de cada filho deve caber dentro da janela de seu pai; primeiro byte do corpo ≠ 0x20 → erro do analisador −16; um filho ultrapassando sua janela → −2; qualquer um faz o manipulador 9065 (0xeb50c) escrever o final .

O Builder está implementado e validado offline (core-rust/watchface_struct.rs: SceneNode / serialize / parse_scene / validate_container / build_container / build_container_raw; CLI cmfwatch-wfgen reframe):

  • scene_roundtrip_identity — todos os 103 mostradores da loja: parse_scene→serialize reproduz a cena byte a byte (os lens aninhados recalculados correspondem) e validate_container passa em todos.
  • build_reframe_identity / CLI reframe — remontar o .bin inteiro do zero reproduz o arquivo byte a byte exceto por 1 byte de preenchimento de nome (@0x17; não é checksum).
  • build_container_synthetic — compõe um novo mostrador (fundo + desenhável aninhado em 20→21) que passa pela invariante exata do firmware (build_container emite janelas aninhadas corretas).
  • validate_rejects_bad_containers — rejeita um corpo plano (→ , o bug histórico ) e um filho que ultrapassa sua janela (→ ).

🟡 Ainda não comprovado (precisa do relógio, não bloqueante): enviar um sintético criado do zero via 9075 e vê-lo renderizar — a prova estrutural offline já cobre o que causou a rejeição 0a.

11.8 Refinamentos de fidelidade de renderização (2026-07-02, mostrador 275 "SlopeTime")

Cruzou-se a renderização do wfweb com as miniaturas oficiais da loja (oráculo de pixels em todos os 103 mostradores) e fecharam-se quatro lacunas:

  • X/Y do desenhável/ponteiro são i16 (com sinal). ✅ Âncoras podem ser negativas para elementos que se estendem para fora da tela — por exemplo, o ponteiro vermelho dos segundos do 275 está em Y = 0xFFFC = −4 (um sprite de 30×281, fonte 0x12, rotacionado do centro para fora da borda superior). Ler X/Y como u16 (65532) fazia o guardião descartá-lo. Analise ambos como com sinal e permita uma pequena faixa negativa.
  • Os dígitos do relógio digital podem ser 0x60 img_numbers de nível superior (não apenas dentro de um grupo 0x68), e a fonte de dados real é o u8 no offset −5 do registro — a varredura direta do atributo 82 está sistematicamente com deslocamento de um aqui e captura o atributo do próximo irmão (no 275 o dígito dos minutos pegou o dia da semana 0x18). O "10:10" do 275 = hora 0x07@X≈306 + min 0x0b@X≈369 com o como um estático adjacente entre eles, cada um um atlas de 11 glifos (). ⚠️ Ao corrigir X/Y de , os , ou uma reexportação corrompe esses bytes (quebra a mesma pegada → ).

Também: as miniaturas oficiais da loja são renderizadas às 10:10 (horário clássico de marketing), não às 10:12 — combinar o horário do oráculo para 10:10 reduz a diferença média de pixels visivelmente. O analisador do wfweb agora faz ida e volta em todos os 103 mostradores exatos em bytes (a correção do offset de escrita X/Y acima eliminou as últimas incompatibilidades).

11.9 Pulo do contêiner AOD + fonte img_number independente (2026-07-03, mostrador "Gradient")

  • Separe o contêiner 0x22 AOD em sua própria visão. ✅ O caminhador de cena já pula 0x22, mas a varredura plana de texto/número percorria todo o [0x30, firstAsset) — então emitia a variante sempre ativa (AOD) de cada elemento como uma camada normal. No "Gradient" o atlas de data cinza da AOD (offset em 0x22) desenhava sobre o vermelho normal. Correção: marque cada registro 0x22 com layer.aod=true (com seu próprio conjunto de dedup) e deixe renderAt(…, aod) mostrá-los apenas no modo AOD (o modo normal oculta as camadas aod; o modo AOD oculta as normais; o fundo é trocado por setAod e sempre desenha). Ganho líquido do oráculo no modo normal em todo o corpus (284: 31%→21%, +18 outros) — as variantes AOD estavam sobrepondo muitos mostradores — e a alternância AOD do editor agora mostra o layout real sempre ativo em vez dos normais. A AOD real é uma tela preta (sem cena escurecida): se o mostrador não possui um quadro de fundo AOD dedicado (dial.aod), a cena normal é ocultada no modo AOD para que renderize preto + os elementos em suas próprias cores. Os AOD são analisados via o caminhador de cena também (agora ele percorre o contêiner marcando os desenháveis como , em vez de deixá-los para a varredura plana onde seu pivô não correspondia → "não posicionados"); os ponteiros AOD giram no centro da tela (o às vezes carrega um x/y de ponteiro fora do centro que o firmware ignora — por exemplo, a hora do Gradient ). O editor também expõe isso como uma (§UI): cada tela mostra apenas suas próprias camadas e as edições persistem independentemente. A renderização em modo normal é idêntica byte a byte em todo o percurso; a ida e volta permanece exata em bytes em todos os 103 mostradores.

11.10 CONTAGEM DE DÍGITOS do img_number — o byte 40 01 00 XX (✅ confirmado pelo firmware)

Quantos dígitos um img_number desenha é um único byte no registro de campo — o byte de dados XX do sub-registro de atributo 40 01 00 XX do elemento (o sub-registro 0x40 que fica após a tabela de quadros 61 [count][base][glyph-ids]):

  • nibble baixo XX & 0x0F = número de slots de dígito (0 ⇒ padrão do firmware 7).
  • bit 7 0x80 = zero-pad (mostrar zeros à esquerda, por exemplo, "09" vs "9").

Confirmado por desmontagem do firmware (imagem XIP 0x10000000; rotina de renderização 0x100d8e60): NDIG = ldrb[40sub+3] & 0x0F (→7 se 0); o valor é limitado value % 10^NDIG e exatamente NDIG glifos são desenhados MS primeiro, zeros à esquerda suprimidos a menos que bit7. O u16 após a fonte (60 para data, 1000 para kcal) NÃO é a contagem — ele alimenta apenas a inserção do glifo separador de milhares/milhões (cmp #1000/#1000000), é por isso que editá-lo não fazia nada. O id da fonte também não limita.

Histograma do corpus sobre todos os 620 campos numéricos corresponde: campos de 2 dígitos (hora/min/seg/data/temp/FC) terminam 40 01 00 02/0x82; kcal …04; passos …05; divisões de relógio de dígito único 0x81. Portanto, o campo de data 40 01 00 82 = 2 dígitos, preenchido com zero — essa é a razão pela qual uma temperatura Fahrenheit reatribuída (≥100) foi truncada.

Correção / editor: wfweb analisa digitCount/digitZeroPad (+digitCountOff) para campos numéricos, expõe "Dígitos" + "Preenchimento com zero" no inspetor, escreve o byte no local (mesma pegada), e a prévia limita/preenche para digitCount para espelhar o firmware. Portanto, reatribuir a fonte de um campo e definir sua contagem de dígitos funciona para qualquer campo (por exemplo, data→temperatura °F → Dígitos 3). Oráculo de modo normal inalterado (0 regressões, 3 pequenas melhorias); ida e volta exata em bytes em todos os 103 mostradores. (A hipótese anterior de "largura de dígitos"/rectW estava errada — largura é apenas layout, não a contagem.)


12. Detalhes de transferência em massa e OTA

A tabela de transferência está na §6. Pontos adicionais confirmados:

  • AGPS/EPO ✅: o primeiro chunk escrito começa com o cabeçalho ASCII 000000010000…. O laço completo init → [A05F ↔ 905F]×N → finish foi observado no fio (~892 chunks).
  • OTA de firmware (9040–9042, finish 9041) 🔎: estrutura mapeada; payload INIT2 = bytes de versão (por exemplo, 0b 00 00 39 = 11.0.0.57). Não testado em campo (o aplicativo desabilita a atualização de FW aqui). As imagens de firmware parecem ser não assinadas — a integridade é apenas CRC32 (nenhuma chave assimétrica observada em RE).
  • ⚠️ Como OTA e FACTORY_RESET (009A 0001) compartilham a sessão autenticada, uma única autenticação BLE válida é suficiente para limpar ou (em princípio) brickar o relógio. Manuseie com cuidado.

13. Sensores

✅ Hardware exposto via BLE:

  • PPG óptico — frequência cardíaca (manual/automática/treino/repouso), SpO₂ e estresse derivado de HRV.
  • Acelerômetro de 3 eixos — passos, distância, calorias, estadiamento do sono, levantar o pulso, cadência.
  • GNSS/GPS (assistido por AGPS) — trilha de treino (WORKOUT_GPS) e envio de localização (GPS_PUSH).

Não há barômetro/altímetro, bússola, giroscópio ou sensor de temperatura da pele/corpo. Um termistor NTC interno (temperatura da placa/bateria) existe, mas é legível apenas via canal AT (AT GETNTCTEMP, §14) — o fluxo de histórico de temperatura da pele 0155 está vazio neste SKU.

Sequestros de widget de dados (não há API real de complicação/vinculação de dados — veja §11.5): os campos de texto existentes do relógio podem ser reaproveitados para mostrar dados externos de relance. Comprovado ✅: a string de cidade do clima (WEATHER_SET_1, por exemplo, "BRA 2x1 ARG" apareceu no widget) e os campos de música artista/faixa; a lista de contatos (20 × nome[32]+número[25]) funciona como um painel de dados rolável. Todos são envios, não complicações persistentes.


14. Canal AT de fábrica / shell (77d4ff01 / 77d4ff02)

Um canal de comandos AT em texto simples separado, independente do protocolo emoldurado. ✅ testado ao vivo:

  • Leitura: AT GETSECRET (segredo de pareamento de 16 bytes), GETVERSION, GETSN, GETNAME, GETPID, GETBATLV (mV bruto, por exemplo, 3853mv), GETGSENSOR (aceleração bruta em g, X=… Y=… Z=…), GETNTCTEMP (°C, NTC interno).
  • Escrita / atuação: AT SETMOTOR=1 (vibrar o motor), SETHR/SETHRV/SETSPO2=… (injeção de teste de sensor), SETLCDSWITCH/SETGPSSWITCH/SETKEYSWITCH.

As respostas terminam em ,OK. Comandos SET* geralmente executam, mas podem não ecoar ,OK via BLE — confirme caso a caso.


15. Recursos bloqueados pelo firmware / indisponíveis (🔎 RE do firmware)

Alguns recursos estão presentes no firmware, mas desabilitados por SKU/região e não são acessíveis pelo telefone/BLE — eles precisam de uma modificação do firmware, o que está fora do escopo aqui:

  • ChatGPT por voz — portão = id de recurso 0x9e do ux2sys, inicializado a partir de NVRAM/EFUSE/região na inicialização; neste SKU o sinalizador de suporte 908b = 00. Não influenciável por telefone, conta ou BLE (confirmado por experimento + RE). O aplicativo é apenas um relé; o áudio vai telefone → servidores da Nothing.
  • Pressão arterial — um subsistema completo existe no firmware, desligado por SKU/região.
  • Alipay / pagamento NFC — IU completa presente, apenas SKU China.
  • Ausente em hardware/firmware: ECG, SOS/emergência, NFC genérico.

16. Tabela de getters de complicação (🔎 referência interna do firmware)

Não é necessário para construir um cliente BLE — incluído para completude. O renderizador de mostrador do firmware vincula cada slot de complicação a um id de getter numérico (tabela de despacho de 142 entradas). Ids selecionados: 0x07 hora, 0x0a ângulo de relógio combinado, 0x0b minuto, 0x0f segundo, 0x18 dia da semana, 0x19 frequência cardíaca, 0x1b % da bateria, 0x24 temperatura, 0x36 passos, 0x70/0x71/0x72 ângulo do ponteiro de hora/minuto/segundo, 0x25–0x27 % de meta. Complicações de anel/arco indexam uma folha de quadros pré-renderizada (por exemplo, 50% = quadro 50 de 100), não um arco por pixel — os quadros estão embutidos no .bin que você envia (§11.5), portanto nenhum pacote RES externo é necessário.


Quick-cards (tiles da tela inicial) — QUICK_CARD (906D) ✅

Os tiles da tela inicial do relógio. O telefone apenas escolhe quais tiles mostrar e em qual ordem — os tiles são renderizados pelo firmware (nenhum canal de conteúdo). Primeiro byte do payload = subcomando: 00 = GET, 01 = SET; ambos usam 0x906D (0x906C está listado, mas não usado — consultá-lo dá timeout). Resposta = A06D.

🛑 Enviar um assemblyId inventado limpa as telas do relógio (ele aceita a lista, não consegue corresponder aos ids, não mostra nada). Envie apenas ids que você leu de volta via GET; recupere via o aplicativo oficial ou um reset de fábrica.

Resposta GET ✅: status(1) ‖ 00 ‖ N(1) ‖ N × grupo, grupo = tag=01 ‖ K(1) ‖ K×(assemblyId, sportId). Quadro real: 01 00 04 01 02 5d00 6100 01 03 1900 2e00 2300 01 03 5c00 0400 5a02 01 03 4800 5100 5300 = 4 telas / 11 cartões (5a02 = cartão Esporte, sportId 2).

Slots: cada tela tem 4 slots. O tipo de um cartão define seu tamanho — circular/square = 1 slot, rectangle = 2 slots. A validação é aritmética pura de slots (Σ ≤ 4 por tela); nenhum cartão mutuamente exclusivo. sportId é 0 exceto em cartões Esporte (87–91). Os ids 64 e 95 não existem.

Catálogo de assemblyId (cada tipo lógico = um intervalo contíguo de 6 variantes de estilo _0.._5; 0 = slot vazio):


Os layouts de bytes acima foram reconstruídos a partir do firmware (1.0.0.73), do APK oficial (3.5.7) e de capturas ao vivo descriptografadas contra um dispositivo real. A implementação de referência para este projeto reside em core-rust/src/{commands,frame,crypto,health,session}.rs (Rust) e nas ferramentas Python cmftool/ (pair.py, session.py, wf_codec.py, upload_custom.py, …).

Baixar ferramenta
PropósitoServiçoCaracterísticaPropriedades
Escrita de comando0000fff0-0000-1000-8000-00805f9b34fb0000fff2-…Write
Notificação de comando0000fff0-…0000fff1-…Notify
Escrita de shell (AT)—77d4ff01-2fe2-2334-0d35-9ccd078f529cWrite
Notificação de shell (AT)—77d4ff02-…Notify
Escrita de dados em massa—02f00000-0000-0000-0000-00000000ffe1Write
Notificação de dados em massa—02f00000-…ffe2Notify
Nomecmd1,cmd2
TIMEFFFF 8004
FIRMWARE_VERSION_GET / _RETFFFF 8006 / FFFF 0006
SERIAL_NUMBER_GET / _RET00DE 0002 / 00DE 0001
BATTERY005C 0001
TRIGGER_SYNC005C 0002
USER_INFO_SET / _RET 🔎✅0095 0001 / 0095 0003
FACTORY_RESET009A 0001
DEVICE_REBOOT 🔎FFFF 9080
RESOLUTION_GET 🔎 (→ 466×360)FFFF 907F
GPS_PUSH / _RETFFFF 906A / FFFF A06A
UNBIND_SET / _RETFFFF 907A / FFFF A07A
Nomecmd1,cmd2
AUTH_PHONE_NAMEFFFF 8049
AUTH_WATCH_MACFFFF 0049
AUTH_PAIR_REQUEST / _REPLYFFFF 8047 / FFFF 0048
AUTH_NONCE_REQUEST / _REPLYFFFF 804B / FFFF 004C
AUTHENTICATED_CONFIRM_REQUEST / _REPLYFFFF 804D / FFFF 0004
AUTH_FAILEDFFFF A061
Nomecmd1,cmd2
APP_NOTIFICATION0065 0001
INCOMING_CALL ⚠️0064 0001
CALL_REMINDER_REQUEST / _RESPONSEFFFF 9066 / FFFF A066
FIND_PHONE005B 0001
FIND_WATCH005D 0001
FIND_WATCH_TOGGLEFFFF 9069
SMS_MESSAGE_PUSH / _RETFFFF 906E / FFFF A06E
QUICK_REPLY_SET / _RETFFFF 9073 / FFFF A073
Nomecmd1,cmd2
ALARMS_SET / _GET0063 0001 / 0063 0002
CONTACTS_SET / _GET00D5 0001 / 00D5 0002
STANDING_REMINDER_SET / _GET0060 0001 / 0060 0002
WATER_REMINDER_SET / _GET0061 0001 / 0061 0002
TASK_REMINDER_SET / _RET ⚠️FFFF 9072 / FFFF A072
Nomecmd1,cmd2
GOALS_SET / _ACK005E 0001 / 005E 0003
UNIT_LENGTH / _ACKFFFF 9067 / FFFF A067
UNIT_TEMPERATURE / _ACKFFFF 9068 / FFFF A068
TIME_FORMAT / _ACK005F 0001 / 005F 0003
WAKE_ON_WRIST_RAISE / _GET / _ACK0062 0001 / 0062 0002 / 0062 0003
LANGUAGE_SET / _RETFFFF 9058 / FFFF A06B
HEART_MONITORING_ENABLED_SET / _GET009B 0001 / 009B 0002
HEART_MONITORING_ALERTSFFFF 9059
DO_NOT_DISTURB / _GET0099 0001 / 0099 0002
SPORTS_SET / _GET00DC 0001 / 00DC 0002
SPORT_LINKAGE_SET / _RETFFFF 9076 / FFFF A076
SPORT_DATA_SYNC 🔎 (FC/cal/passos ao vivo)FFFF 9078 / FFFF A078
FEMALE_CYCLE_SET / _RETFFFF 9071 / FFFF A071
SLEEP_CONFIG_SET / _RET (minutos alvo)FFFF 9074 / FFFF A074
WORLD_CLOCK_GETFFFF 906F
WORLD_CLOCK_DST_SET / _RETFFFF 9083 / FFFF A083
VITALITY_GET / _RETFFFF 9079 / FFFF A079
VITALITY_SW_SET / _RETFFFF 9070 / FFFF A070
Nomecmd1,cmd2
DIAL_COMMAND_SET / _RET (listar/reordenar/selecionar)FFFF 9055 / FFFF A055
DIAL_CONFIG_SET / _RETFFFF 9075 / FFFF A075
CHANGE_DIAL (⚠️ inerte em 1.0.0.73 — não usar)009F 0001
QUICK_CARD_SET/GET / _RET (ambos em 906D)FFFF 906D / FFFF A06D
Nomecmd1,cmd2
ACTIVITY_FETCH_1 / _2FFFF 8005 / FFFF 9057
ACTIVITY_FETCH_ACK_1 / _2FFFF 0005 / FFFF A057
ACTIVITY_DATA0056 0001
SLEEP_DATA / _GET0058 0001 / 0058 0002
SPO20055 0001
STRESS009D 0001
HEART_RATE_MANUAL_AUTO0053 0001
HEART_RATE_RESTING00DA 0001
HEART_RATE_WORKOUT00E0 0001
SKIN_TEMP_HISTORY 🔎 (vazio neste SKU)0155 0001 / 0155 0002
WORKOUT_SUMMARY / _V30057 0001 / 0160 0001
WORKOUT_GPSFFFF A05A
DomínioINIT1 req/respostaINIT2 req/respostaCHUNK req/escritaFINISH ack1/ack2
Mostrador (foto)8052/00529063/A063A064/9064A065/9065
Mostrador (estruturado/troca)8052/00529075/A075A064/9064A065/9065
Firmware9052/A0529040/A040A042/9042A041/9041
AGPS/EPO905E/A05E—A05F/905FA060/9060
OffsetTamanhoCampo
04timestamp (segundos epoch)
44passos
84distância (m)
124calorias
1616reservado (observado 0)
OffsetTamanhoCampo
04session_start (epoch, UTC)
44wakeup (epoch, UTC)
82total_deep_s
102total_core_s
122total_rem_s
142total_awake_s
162⚠️ [incerto] (id/pontuação da sessão? valores observados não correspondem às somas dos registros)
ac 49 1f 01
  • CONTACTS_SET (00D5 0001) ✅: N × 57 bytes = name(32) ‖ phone(25). A interface do relógio mostra até 20.
  • ALARMS_SET (0063 0001) ✅ — corrige Gadgetbridge (que colocava o rótulo no final, preenchido com 0xff — errado). 40 bytes por alarme, big-endian: secondsOfDay(i32) ‖ index(u8) ‖ enabled(u8) ‖ repetition-bitmask(u8) ‖ flag(u8) ‖ label[32] UTF-8. O rótulo está no offset 8 e aparece no relógio. repetition = máscara de bits de dias da semana (0 = uma vez); flag é ⚠️ [incerto] (marcador de uma vez?). Exemplo (13:30, idx 2): 0000bdd8 02 01 15 00 "Alarm…".
  • GOALS_SET (005E 0001) ✅ — o aplicativo oficial e a implementação de referência usam o DailyTargetBean v1 big-endian de 10 bytes: steps(u32 BE) ‖ distance_m(u32 BE) ‖ calories_kcal(u16 BE). (Esta é a forma do Gadgetbridge; relatos anteriores de que o relógio "ignorava" era um bug de descriptografia de sessão obsoleta, não um problema de payload.) 🔎 A engenharia reversa do firmware também mostra uma variante estendida 29 bytes maior (adiciona sleep_min/exercise_min/stand_h + 6 flags de ativação, todos u32 BE após um prefixo flag(u16 LE), com intervalos aplicados: passos 2000–30000, dist 1000–99000, cal 100–5000, sono 360–720, exercício 30–90, em pé 6–16) — não é o caminho padrão do aplicativo; prefira a forma de 10 bytes a menos que precise dos alvos extras.
  • STANDING_REMINDER / WATER_REMINDER (0060/0061 0001) ✅: 11 bytes: enabled(1) ‖ threshold_min(u16 LE) ‖ dndStart(u32 LE) ‖ dndEnd(u32 LE). Note que a "janela ativa 08:00–22:00" mostrada na interface é um padrão fixo de firmware e não é transportada no payload.
  • SPORTS_SET (00DC 0001) ✅: count(1) = 36 slots ‖ activityTypeCode[36] (códigos ativos então preenchimento 00 ). Seleciona quais esportes aparecem no menu de treino do relógio.
  • HEART_MONITORING_ENABLED (009B 0001) ✅: byte kind — 01 = FC 24/7, 02 = SpO₂, 04 = estresse (medido a cada 30 min).
  • HEART_MONITORING_ALERTS (FFFF 9059) ✅: desativado = 00; ativado = 01 ‖ hrLow ‖ hrHigh ‖ sportHrHigh ‖ spo2Low ‖ 00 00 00 00 (um limite 0/255 = "sem limite").
  • FEMALE_CYCLE (FFFF 9071) ✅: 01 ‖ predictionOpen ‖ notifySwitch ‖ cycleStartSwitch ‖ cycleStartNotifyBefore ‖ ovulationStartSwitch ‖ ovulationStartNotifyBefore ‖ fertileStartSwitch ‖ fertileStartNotifyBefore ‖ period(1) ‖ cyclePeriod(1) ‖ cycleStartDate(u32) ‖ markStart(u32) ‖ markEnd(u32) (capturado: period=5, cyclePeriod=0x1c=28).
  • QUICK_REPLY (FFFF 9073) ✅: TLV — count(1) ‖ total(1) ‖ [id(1) ‖ len(u16 LE) ‖ msg-UTF8]… (7 respostas padrão capturadas e descriptografadas).
  • WORLD_CLOCK (FFFF 906F) ✅: envia IDs de cidade numéricos, não nomes (01 ‖ count ‖ cityId(2 BE)…); o relógio mapeia ids de uma tabela interna. Config DST FFFF 9083 = count ‖ [id(u16 LE) ‖ dst(u16 LE) ‖ start(u32 LE) ‖ end(u32 LE)]….
  • MUSIC_INFO_SET (FFFF 905C, 131 B) ✅: state(1: 0=nenhum/1=pausado/2=tocando) ‖ volume(1) ‖ volumeMax(1) ‖ track(64) ‖ artist(64). O relógio também envia MUSIC_BUTTON (A05D) de volta.
  • WEATHER_SET_1 (FFFF 906B, 199 B) ✅ — use este: 7×9 bytes dias + 24×2 bytes horas + cidade(32) + 7×8 bytes nascer/pôr do sol (LE). Temperaturas codificadas como (temp_c + 100) & 0xFF. ⚠️ O mesmo payload enviado em WEATHER_SET_2 (0066 0001) não atualiza o widget de clima no Pro 2 — sempre use 906B. (A string da cidade também é um vetor comprovado de sequestro de dados — veja §13.)
  • FIND_WATCH (005D 0001) ✅: payload 0x01 → relógio toca/vibra (+ ACK 005D 0003).
  • GPS_PUSH (FFFF 906A) ✅ — big-endian, longitude primeiro: 16 bytes ts(u32 BE) ‖ lon×1e7(i32 BE) ‖ lat×1e7(i32 BE) ‖ 00 00. Validado para uma localização real.
  • WORKOUT_GPS (FFFF A05A) ✅ — little-endian, longitude primeiro: 12 bytes ts(i32) ‖ lon×1e7(i32) ‖ lat×1e7(i32).
  • TIME (FFFF 8004): veja §7.
  • cfbppraster (após LZ4)uso
    42RGB565-LEfundo opaco (FULL/THUMB)
    53RGB565-LE (2 B) + alpha (1 B) por pxsprites anti-serrilhados (glifos, mãos, ícones)
    13 (0x0d)0.5máscara alpha de 4 bits; firmware tinge em tempo de execuçãoatlas de glifos dígitos
    24 (0x18)4RGBA8888camadas de cor total (incluindo a sempre ligada aodImage)
    1—JPEG/JFIF (ff d8 ff), extrair com qualquer descodificadorraros quadros de animação
    0a
    0x61
    NotEnvelope
    0a
    ChildOverflow
    :
    61 0a 00
    −18/−16
    offsets de escrita também devem se mover
    0a
  • Slots de complicação multivariante: a métrica ativa NÃO está no .bin. ⚠️ Uma complicação configurável é criada como N nós de grupo 0x68 empilhados no mesmo (x,y), cada um vinculado a uma fonte diferente (os dois círculos do 275: 0x1e/0x6a/0x48/0x24/0x19 por slot — ids de opção/estilo, não a métrica mostrada); os dois círculos são idênticos byte a byte exceto por seu retângulo + um byte de instância (0x79/0x7a). Qual métrica aparece (PASSOS vs KCAL vs …) é estado de RAM/configuração do dispositivo, portanto uma prévia estática não pode reproduzi-la a partir do arquivo — apenas melhor esforço.
  • Complicações inativas ancoradas na borda (por exemplo, texto de bpm em (446,0), visto nos mostradores 275/302/325/365/375) são slots que o firmware não desenha na visão padrão — seu valor nem cabe antes da borda da tela. Trate como oculto na prévia.
  • 0x22
    ponteiros
    0x22
    aod
    0x22
    @69,209
    IU de edição Normal|AOD isolada
  • 0x60 img_number independente (cnt=10) — fonte em −5, deslocamento de um para frente. ✅ Mesmo deslocamento de um que §11.8, mas para números não-relógio: a data do "Gradient" estava em (203,80) centro superior com fonte 0x17, mas a varredura direta 82 capturou o getter de ângulo do ponteiro vizinho (0x0a) e a posição do ponteiro → o número renderizou no local do ponteiro com uma fonte fictícia. Correção: para um 61 0a 00 img_number em um wrapper 0x60, confie em −5/−18/−16 quando a fonte direta é impossível para um número (fonte-0 ou um getter de ângulo de ponteiro 0x0a/0e/12/70/71/72) e a posição −18/−16 é válida e diferente de zero (o guardião não zero pula dígitos filhos de grupo com relX=0).
  • 0x17 = data (dia do mês), 0x24 = temperatura — distintos. O mostrador 340 usa ambos (0x17 "Jun 09" e um 0x24 temperatura separado), então 0x17 é data, não temperatura. Um mostrador cujo relógio mostra uma temperatura em um slot 0x17 é uma complicação configurada pelo usuário (estado do dispositivo), não o padrão do arquivo.
  • deccartãodeccartão
    0slot vazio49–53,97–98Clima
    1–6Passos54–58Timer
    7–12Calorias59–62Respiração
    13–18Em pé63,65–67Cronômetro
    19–24Atividade moderada68–71Bateria
    25–30Frequência cardíaca72–76Recentes
    31–36SpO₂77–81Contatos
    37–42Estresse82–86Mostrador / telefone
    43–48Sono87–91Esporte (sportId ≠ 0)
    92Música93/94/96Registro de atividade / PAI / Ciclo