Sumário 🔗
- Introdução
- O que é um módulo piggyback
- O app original
- Duas fontes de verdade
- O protocolo, byte a byte
- O mapa e o quebra-cabeça do CRC
- Os desafios que não estavam no código
- A arquitetura do app novo
- Validando dentro do carro
- A nova interface
- Ganhos em estabilidade e performance
- Conclusão
Introdução 🔗
Todo carro com módulo de potência vem com um app. Nem todo app merece o carro.
Meu carro tem um módulo Bpower: uma caixinha que fica entre o sensor de pressão do coletor e a central do motor, controlada pelo celular via Bluetooth.
O app oficial funciona. Mais ou menos.
Ele mostra n/c em quase tudo até conectar. Tem botões que não fazem nada. Não reconecta sozinho se você reinicia o Bluetooth. E, como descobri da pior forma, às vezes mostra um modo de potência que não é o que o módulo está usando.
Então fiz o que qualquer engenheiro faria: abri o app, descobri como ele conversa com o módulo e escrevi outro.
Este texto conta como foi essa engenharia reversa, os problemas que só apareceram no tráfego real e o que mudou entre o app original e o novo.
O que é um módulo piggyback 🔗
Um módulo piggyback não reprograma a central do motor (ECU). Ele “pega carona” no chicote.
O módulo intercepta o sinal do sensor de pressão do coletor (MAP) e entrega para a ECU um valor um pouco menor do que o real. A ECU acha que o turbo está entregando menos pressão do que deveria e compensa pedindo mais.
Quanto menor o sinal, mais a ECU compensa. Os níveis de potência do app (Eco, Stock, Sport 1 a 7, Race) escolhem o quanto o módulo mexe nesse sinal.
Isso parece teoria até você medir. No teste no carro (detalhes mais abaixo), com um leitor OBD gravando o que a ECU enxergava ao mesmo tempo em que o módulo mandava a pressão real, a ECU via 7% a 8% menos pressão do que existia de fato nas faixas de maior intervenção do mapa.
O app original 🔗

O app original é um app React Native. Visualmente ele lembra um painel de carro: mostrador grande de boost, um botão de “Exhaust”, um de “Pedal” e uma barra embaixo.
Olhando com calma, os problemas aparecem:
- Antes de conectar, tudo é
n/c. Não existe nenhuma informação de em que etapa a conexão está - Os controles de Pedal e Exhaust são só interface. Nenhum comando Bluetooth é enviado quando você mexe neles
- Se você desliga e liga o Bluetooth do celular, o app não reconecta. Ele inicia o scan com o rádio desligado e não tenta de novo
- O nível de potência 0 aparece como Eco, o que gera confusão de verdade (já chego nisso)
Nada disso impede o módulo de funcionar. Mas tudo isso atrapalha quem está dirigindo.
Duas fontes de verdade 🔗
Engenharia reversa de protocolo fica muito mais fácil quando você tem duas fontes que se confirmam.
Fonte 1: o código.
Em apps React Native com Hermes, o JavaScript não vai em texto puro dentro do APK. Ele vai compilado em bytecode Hermes no arquivo index.android.bundle. Com o hermes-dec dá para decompilar esse bytecode em algo legível.
Toda a lógica de comunicação estava concentrada em uma única tela, a ModuloScreen: scan, conexão, handshake, polling, escrita do mapa. O código mostra o que o app pretende fazer.
Fonte 2: o tráfego.
O Android tem uma opção de desenvolvedor chamada log de espionagem HCI do Bluetooth. Com ela ligada, o sistema grava cada pacote que passa pela controladora Bluetooth em um arquivo btsnoop_hci.log.
Junto com o logcat do app, isso mostra o que realmente acontece no ar: cada escrita, cada notificação, com timestamp.
Um script em Python decodifica esse log e extrai só o que interessa: as escritas na característica do módulo e as notificações que voltam.
Cruzar as duas fontes foi o que destravou tudo. O código explica a intenção. A captura mostra a realidade, incluindo o que o código não trata.
O protocolo, byte a byte 🔗
O módulo é um BLE-UART transparente, do tipo HM-10: um serviço FFE0 com uma característica FFE1, por onde passam escritas e notificações.
O app pede MTU de 247 bytes. O módulo aceita 23. Na prática, cada escrita ou notificação carrega no máximo 20 bytes de dados.
O QR code colado no módulo traz tudo o que o app precisa para encontrá-lo e parear: o MAC, o PIN de pareamento e um multiplicador de calibração do sensor.
Os comandos são letras ASCII:
| Bytes | ASCII | Função |
|---|---|---|
52 | R | Dados em tempo real (boost e rpm) |
53 | S | Versão do firmware e serial |
45 | E | Lê o bloco de configuração (195 bytes) |
54 | T | CRC-32 do mapa gravado no módulo |
79 … | y | Escreve configuração (troca de potência) |
50, 5A, 42 | P, Z, B | Envio e gravação do mapa |
Agora vem a parte curiosa: as respostas não têm cabeçalho.
Não existe um byte dizendo “isto é a resposta do R”. O app original guarda um modo global com o último comando enviado e interpreta a próxima notificação de acordo com ele.
| Modo | Comando | Resposta esperada |
|---|---|---|
| 0 | E | 195 bytes, acumulados em 10 notificações |
| 1 | R | 9 bytes, uma notificação |
| 2 | S | 23 bytes, em duas notificações |
| 3 | T | 4 bytes |
| 4 | y | OK ou ER |
Isso significa que só pode existir um comando em voo por vez. E que uma notificação atrasada pode ser lida como resposta do comando errado.
O pacote de tempo real é um bom exemplo. Esta é uma resposta real do R, capturada com o motor em marcha lenta:
100 CE 02 8B 00 C6 FB 43 8B
2└─┬─┘ └─┬─┘ ├┘ └────┬────┘
3 boost rpm │ CRC-32 (little-endian) dos 5 bytes anteriores
4 = 206 = 651 status
Pressão bruta 206, 651 rpm, status 0. Os quatro bytes finais são um CRC-32 dos cinco primeiros.
Na captura, 505 de 505 pacotes tinham CRC válido. Ainda assim, o app novo valida todos e descarta o que não bate:
1static RealtimeSample? tryParse(List<int> bytes, {required int fwNumber}) {
2 final len = lengthFor(fwNumber);
3 if (bytes.length != len) return null;
4 final payloadLen = len - 4;
5 if (Crc32.compute(bytes, 0, payloadLen) != crc32FromLe(bytes, payloadLen)) {
6 return null;
7 }
8 return RealtimeSample(
9 rawBoost: (bytes[0] << 8) | bytes[1],
10 rpm: (bytes[2] << 8) | bytes[3],
11 status: payloadLen == 5 ? bytes[4] : 0,
12 );
13}
O valor bruto vira pressão com o multiplicador do QR code, e o boost exibido é essa pressão absoluta menos a pressão atmosférica.
O mapa e o quebra-cabeça do CRC 🔗
O “mapa” é um binário que o app baixa do servidor do fabricante usando o serial do módulo. São 991 bytes com um layout bem definido:
| Offset | Conteúdo |
|---|---|
| 0–323 | Tabela 1: 18×18, RPM × pressão |
| 324–395 | Eixos da tabela 1 (18 pontos de RPM e 18 de pressão) |
| 398–721 | Tabela 2: 18×18 (zerada neste mapa) |
| 722–793 | Eixos da tabela 2 |
| 797–991 | Bloco de configuração |
O detalhe que fez tudo se encaixar: os últimos 195 bytes do mapa são exatamente o que o comando E devolve. E o nível de potência mora no nibble alto do byte 797.
Isso cria um problema interessante. Quando você troca a potência, o módulo altera o byte 797, e o CRC do mapa gravado muda. Como saber, então, se o mapa do módulo é o mesmo do servidor?
O app original resolve com força bruta: para cada nível de 0 a 10, ele altera o byte 797 na cópia do servidor, calcula o CRC-32 e compara com o que o comando T devolveu. Se algum bater, o mapa é o mesmo e você ainda descobre em qual nível ele está.
Se nenhum bater, aparece o famoso “Novo mapa online!”.
O envio do mapa também é todo cronometrado: anuncia o CRC (P), sinaliza o início (Z), manda 50 escritas de 20 bytes (cerca de 3,3 s com MTU 23), confere o CRC com T e só então manda gravar na flash (B). O módulo não responde ao B, então o app simplesmente espera 4 segundos.
Os desafios que não estavam no código 🔗
Algumas coisas só apareceram com o módulo de verdade, dentro do carro.
O E que não responde de primeira.
Nas capturas, o primeiro pedido de configuração da conexão ficou sem resposta. O app original já tinha um retry para isso (“GetConfig - no response”). O novo reproduz o mesmo comportamento, porque o firmware claramente depende dele.
Notificações fora de hora.
Às vezes chegam notificações cheias de FF depois que a resposta já foi montada. No modelo sem cabeçalho, isso pode virar dado errado. O app novo descarta qualquer notificação que chegue fora de um comando em andamento.
Pareamento com PIN.
No Android, o PIN do QR code precisa ser entregue ao sistema durante o pareamento, sem o usuário digitar nada. Isso exigiu um BroadcastReceiver nativo respondendo ao pedido de bond. No iOS não existe essa API: o app mostra o PIN e o usuário digita no alerta do sistema.
O boost fantasma. Com o motor desligado, o app mostrava entre 0,03 e 0,09 bar de boost.
A fórmula original calcula a pressão atmosférica pela altitude do GPS. O problema é que o sensor do módulo lê um pouco acima disso. No teste, o sensor do módulo marcou 95,7 kPa com o motor parado, contra 93,0 kPa do barômetro da ECU: 2,7 kPa de diferença, a mesma ordem de grandeza do boost fantasma.
A solução foi um zero automático: com o motor parado, o app aprende o offset entre o sensor e o GPS (mediana das amostras com rpm 0) e corrige a referência. Como o que se aprende é o offset e não a pressão absoluta, a correção continua valendo quando o carro sobe ou desce a serra.
O nível que não era Eco. Durante os testes, o módulo estava no nível 0, e a tela dizia Eco, como no app original. Resultado: puxadas que pareciam ter sido feitas no Eco tinham sido feitas em outro nível. Hoje o nível 0 aparece como “Nível 0”. Parece detalhe, mas foi o tipo de detalhe que invalida uma bateria inteira de testes.
O famoso erro 133. O Android às vezes recusa a conexão Bluetooth com o código genérico 133. O app novo tenta de novo sozinho a cada segundo e, em vez de piscar a mensagem por um segundo, mantém a última falha visível no banner até conectar.
A arquitetura do app novo 🔗
O app novo é Flutter, para Android e iOS, com uma regra clara: o protocolo não sabe que existe Bluetooth.
1lib/protocol/ Dart puro: CRC-32, QR code, comandos, respostas,
2 layout do mapa, pressão atmosférica e boost
3lib/services/ BleLink → scan, conexão, pareamento, MTU, notify
4 ModuleSession → um comando em voo, acumulação, retry
5 ModuleController → handshake, polling, watchdog, reconexão
6lib/pages/ Interface
A camada protocol tem cerca de 600 linhas e nenhuma dependência de Flutter. Ela é testada com vetores reais tirados das capturas: o mapa de verdade, os pacotes R de verdade, os pacotes de troca de potência de verdade.
A ModuleSession reproduz o modelo de modos do app original, mas de forma explícita: um comando por vez, notificações acumuladas até o tamanho esperado e qualquer coisa fora de hora descartada. Ela é testada com um transporte falso, sem precisar de módulo.
O ModuleController cuida do ciclo de vida: handshake, polling a cada 150 ms, um watchdog que considera a comunicação perdida após 5 s sem notificação, reconexão automática e pausa do polling quando o app vai para o background.
No total, são 76 testes automatizados.
Validando dentro do carro 🔗
Teste unitário prova que o código segue o protocolo. Não prova que o carro anda diferente.
Para isso, montei um ferramental em Python que, a cada “bateria” (uma marcha e um nível de potência):
- Puxa um bugreport do celular via
adb, com o log HCI e o log do app - Recorta só a janela daquela bateria
- Junta com os dados de um leitor OBD (ELM327) gravando rpm, MAP e pressão barométrica da ECU
- Detecta as puxadas, agrupa pelo nível lido do módulo (não pelo rótulo que eu dei) e compara
Um trecho do relatório da 3ª marcha, só com puxadas completas (de 3000 para 5000+ rpm):
| Nível | Puxadas | Pico de boost (p95) | Pressão vista pela ECU ÷ pressão real |
|---|---|---|---|
| Stock | 2 | 1,20 bar | 0,928 |
| Sport 1 | 1 | 1,43 bar | 0,920 |
| Race | 1 | 1,38 bar | 0,916 |
A amostra é pequena e ainda não permite comparar os níveis entre si com confiança. Mas ela confirma o mecanismo: a ECU vê de 7% a 8% menos pressão do que a real, e essa redução parece crescer um pouco com o nível.
Foi também esse ferramental que pegou o caso do “Nível 0 mostrado como Eco”.
A nova interface 🔗

A nova interface nasceu de um protótipo interativo e foi implementada em Flutter com uma ideia simples: mostrar o que está acontecendo e esconder o que não existe.
- Boost e rotação lado a lado, com pico de boost e a faixa de corte marcada em vermelho no conta-giros
- Shift light: uma fita de LEDs que acende de verde para âmbar e vermelho antes do corte e pisca no corte
- Gráfico dos últimos 6 segundos de boost e rpm
- Potência com Eco, Stock, Sport e Race. Cada troca é uma escrita no módulo, então ela só é enviada quando você solta o controle
- Status da conexão sempre visível: procurando, conectando, conectado, e a última falha, se houver
- Detalhes do mapa com as tabelas decodificadas e a célula em uso destacada em tempo real
- Diagnóstico com o log ao vivo, filtrado por camada (Bluetooth, sessão, módulo, mapa)
Pedal e Exhaust saíram, porque nunca fizeram nada. O shift light e a faixa de corte, que no original ficavam escondidos num modal de configurações, ganharam uma tela própria.
Virando o celular, a tela vira um painel só com os mostradores e atalhos para todos os níveis:

Ganhos em estabilidade e performance 🔗
Comparando só o que está documentado nas capturas e no código do app original:
| Situação | App original | App novo |
|---|---|---|
| Bluetooth desligado e religado | Não reconecta | Detecta e reconecta sozinho |
| Falha de conexão | n/c / “Scanning for device…” | Etapa atual e última falha visíveis |
| Boost com o motor desligado | 0,03 a 0,09 bar fantasma | 0, com zero automático |
| Nível de potência 0 | Exibido como Eco | Exibido como “Nível 0” |
| Pedal e Exhaust | Controles sem efeito | Removidos |
E a performance?
Aqui vale ser honesto: o gargalo é o módulo, não o app. Com MTU de 23 bytes e intervalo de conexão de 30 ms, a ida e volta de um R leva em torno de 50 ms e o polling roda a cerca de 6 leituras por segundo. Gravar um mapa leva uns 3,3 s só de transferência, mais os 4 s que o firmware exige para gravar na flash.
O app novo segue esses tempos de propósito. O firmware foi feito para o ritmo do app original, e acelerar além disso é pedir para perder pacote.
O ganho está em outro lugar:
- O código Dart roda compilado AOT para nativo, em vez de bytecode Hermes interpretado
- O boost exibido passa por uma média exponencial de ~0,3 s. O número para de “dançar” com a pulsação do coletor, sem atrasar a leitura
- Os mostradores animam entre amostras, então 6 leituras por segundo parecem um ponteiro contínuo
- Nenhuma leitura chega na tela sem passar pelo CRC, e notificações fora de hora são descartadas em vez de interpretadas
- 76 testes automatizados, alimentados com dados reais das capturas, rodam a cada mudança
Performance aqui não é fazer mais rápido. É fazer previsível.
Conclusão 🔗
Este projeto começou com uma irritação simples: um app que mostrava n/c e botões que não faziam nada.
Terminou com um protocolo documentado byte a byte, um app que valida cada pacote, um ferramental para medir o efeito real de cada nível de potência no carro e a descoberta de como o módulo realmente funciona.
Algumas lições que valem para qualquer engenharia reversa:
- Cruze fontes. O código diz o que o software pretende fazer. O tráfego diz o que acontece. Os bugs moram na diferença
- Teste com dados reais. Os vetores das capturas viraram testes, e cada regressão aparece antes de chegar ao carro
- Respeite os tempos do hardware. Firmware embarcado raramente perdoa pressa
- Meça no mundo real. O “Nível 0 mostrado como Eco” passaria despercebido em qualquer teste de mesa
Ainda há trabalho: o erro 133 do Android aparece mais do que eu gostaria, e a amostra de puxadas precisa crescer antes de qualquer conclusão sobre qual nível rende mais.
Mas o carro agora tem um app à altura dele.
Uma nota sobre engenharia reversa: todo o trabalho foi feito no meu próprio módulo, para interoperabilidade com um app próprio. Não houve contorno de nenhuma proteção, e identificadores do módulo e endereços do fabricante foram omitidos deste texto de propósito.