Este texto é uma continuação desta série.
Nosso receptorzinho de keyfobs 433MHz chegou no estágio de "produção" e ganhou até uma caixinha e uma antena externa.
Ando meio parado nas atividades de radioamadorismo, então a antena extra do HT ganhou um emprego novo. Não tenho como saber se está casando impedância, etc. mas o alcance parece essencialmente o mesmo, e ficou mais estético que um pedaço de fio.
Como dito no capítulo anterior, o esquema é bem simples: a caixinha apenas capta sinal de keyfobs e publica um tópico MQTT com códigos recebidos, sem filtros. Outro programa (que poderia ser um Home Assistant da vida) dispara algum evento de automação quando recebe determinado código.
O portão eletrônico ainda tem sensibilidade um pouco melhor que nossa caixa, o que não é um problema, mas não podemos deixar barato! Descobri um módulo no AliExpress (ET-RXB-8) que alega ter sensibilidade 7db melhor que o SYN480R, o que essencialmente dobraria o alcance. Comprei um exemplar, e vamos ver o quanto ajuda.
Também já foi dito que a implementação do receptor é um busy loop (classe OOKReceiverBusy), o que é feio. Funciona melhor do que eu esperava, mas é feio. O jitter (variação da latência) do tratador de interrupções do MicroPython é muito alto no ESP32 quando WiFi está ativado.
A rigor, não é pecado usar busy loop em microcontroladores, porque no nível do metal é assim que eles trabalham o tempo todo. Mesmo as implementações de chamadas de sistema análogas a sleep e select/poll são busy loops internamente. Mas no meu frameworkzinho tentamos fazer tudo à moda do UNIX, então busy loops vão de encontro aos nossos princípios, e criam problemas para meu framework.
Por que nosso framework é assim? a) Por uma questão de princípios; b) Por termos a esperança que um dia o MicroPython coloque o ESP32 em "sono leve" por inatividade (é mais fácil que volte Dom Sebastião, mas o suporte de hardware existe, então teoricamente é possível); c) Porque outro microcontrolador com essa capacidade pode popularizar-se no futuro, e aí já estamos prontos.
Para abolir o busy loop, a solução mais elegante é usar um recurso do ESP32 chamado RMT, que permite transmitir e receber trens de pulsos sem ocupar a CPU.
O RMT foi concebido para interfacear com controles remotos infravermelho, mas serve para muitas outras tarefas de "bitbanging" — inclusive os módulos machine.bitstream e neopixel do MicroPython empregam o RMT no porte ESP32. (Na verdade, os keyfobs 433MHz são semelhantes aos controles infravermelho em muitos aspectos; os protocolos são parecidos, e alguns chips desse nicho servem para ambos.)
O MicroPython dá acesso ao RMT, mas apenas para transmissão, inclusive já fizemos uso dele no segundo capítulo. Se quisermos usar em recepção, precisamos arregaçar as mangas e adicionar este recurso ao módulo MicroPython.
O esquema básico é o seguinte: o software inicia a transação. O RMT vai gravando a largura e a polaridade dos pulsos recebidos até encontrar um pulso com largura maior que max, que sinaliza o fim da transação (tudo isso em hardware). Os dados são repassados ao software. Abrir nova transação depende de iniciativa do software (o RMT não tem modo contínuo de RX).
O trem de pulsos dos keyfobs 433MHz começam e terminam com um longo silêncio (5000µs no mínimo), então o RMT consegue isolar um código de keyfob numa transação, sem nenhum pulso demais ou de menos. (A largura do pulso final que fecha a transação não é incluída na transação.)
Pulsos mais curtos que min são considerados glitches e ignorados pelo RMT. Isto permite excluir pulsos impossivelmente curtos que não nos interessam (e que encheriam rapidamente a escassa memória do RMT). Note que pulsos curtos não resetam a transação; eles são simplesmente ignorados. Ainda vamos receber transações com ruído, porém o ruído será filtrado em freqüência.
Nosso receptor considera válidos os pulsos entre 150µs e 1500µs. Se o teorema de Nyquist vale alguma coisa, temos de fazer min <= 75µs para que seja possível distinguir sinal de ruído. (O ideal para nós seria que o RMT descartasse a transação ao receber um pulso curto, mas não é assim que acontece.)
Em nosso caso, pulsos entre 1500µs e 5000µs estão numa faixa proibida: invalidam uma transação, e também não servem como marcadores de fim de transação. Infelizmente o RMT não possui um filtro para largura máxima de pulso válido, então temos de filtrar em software.
Até aqui, o RMT parece absolutamente perfeito para nossa aplicação. Agora, vêm as limitações.
As larguras de pulso têm 15 bits (sem sinal), o que limita sua resolução e valor máximo. O "tick" ou unidade de medida é configurável, na forma de um divisor do clock de 80MHz (e.g. um divisor 80 produz um "tick" de 1µs, e a largura máxima mensurável seria de 32767µs).
O valor de min tem apenas 8 bits, portanto ele possui um teto relativamente baixo (255µs para um "tick" de 1µs). O valor de max possui mais bits (não sei quantos, acredito que sejam 24) mas também tem um teto.
O RMT possui vários canais simultâneos (o número exato depende da variante de ESP32) para que se possa trabalhar diversos pinos de GPIO simultaneamente. Porém, a memória total do RMT é equivalente a 1024 pulsos, compartilhada entre todos os canais.
Essas limitações (e mais outras impostas pelo ESP-IDF, que serão abordadas logo abaixo) não nos impedem de usar o RMT para decodificar keyfobs. Porém está claro que o RMT está longe de ser uma panaceia, e é muito menos poderoso que o PIO do Raspberry Pi Pico (RP2040).
Em primeiro lugar, há uma escassez de exemplos de uso do RMT para RX. A documentação da Espressif é boa, mas é um tópico complexo e fora da minha zona de conforto. Mesmo a Espressif oferece um punhado de exemplos de TX e apenas um de RX. Um exemplo didático e "top-down" é o que ajudaria. Acabei achando este exemplo para framework Arduino que confirmei funciona, e que foi nossa baliza daqui por diante.
Outro ponto é que a API RMT do ESP-IDF (framework da Espressif para seus chips) mudou bastante entre a versão 4.x e 5.x. A API de legado (no estilo 4.x) ainda está disponível no ESP-IDF 5.x e é utilizada pelo módulo RMT do MicroPython (cujo suporte a ESP-IDF 5 é relativamente recente). Para estender o módulo RMT do MicroPython, teria de codificar a parte de RX usando a API de legado, para a qual não tinha um exemplo didático.
Decidi então usar a API RMT atual, e implementar um módulo MicroPython separado, naturalmente copiando o máximo possível de boilerplate do módulo preexistente. Assim poderia me concentrar em "fazer funcionar", sem ter de esquentar a cabeça com questões de compatibilidade, convivência com código TX, etc.
Tive de desligar completamente o módulo RMT preexistente, bem como o módulo bitstream, pois o ESP-IDF não aceita que as APIs velha e nova do RMT sejam referenciadas ao mesmo tempo. (A imagem emite uma mensagem de erro e reseta assim que inicia.)
No desenvolvimento em si, o que deu mais trabalho foi lidar com a "burocracia" de um módulo MicroPython escrito em C. Muito embora eu considere que o MicroPython é mais simples que o Python convencional neste ponto (impressão que vem desde o CircuitPython, um fork do MicroPython). Nem sequer existe Py_INCREF e cia. A coleta de lixo faz todo o serviço.
Adicionamos filtros opcionais "soft" para largura mínima e máxima de pulso, bem como para contagem mínima e máxima de pulsos. Esses filtros são aplicados por software, porém na linguagem C, são muito mais rápidos do que se a filtragem fosse feita em Python.
No caso, só queremos transações com pulsos entre 150µs e 1500µs (pulsos fora dessa faixa invalidam a transação) e contagem entre 49 e 57 (na verdade, exatamente 49 para o keyfob HT1527 ou exatamente 57 para o keyfob HT6B20). Com esses filtros implementados, a camada MicroPython recebe praticamente zero transações de ruído.
Finalmente, tornei o objeto RMT "pollável", ou seja, pode ser tratado como um arquivo e ser monitorado por select() ou poll(). Isto evita o uso de callbacks e integra perfeitamente com nosso framework, que já usa poll() para eventos de rede.
O resultado dos nossos esforços pode ser visto aqui, e o cliente do novo módulo pode ser visto aqui (classe OOKReceiverRMT).. A única desvantagem é ter de usar um build customizado do MicroPython, mas não é a primeira vez que tenho de fazer isso.
Do jeito que está, nem vou tentar kolaborar esse módulo para o MicroPython, porque não tem TX e é incompatível com o módulo preexistente, pois como dito cada um usa uma versão diferente da API RMT do ESP-IDF. Publiquei meu branch como comentário no GitHub do MicroPython, para o caso de alguém se interessar.
Uma alternativa é portar a parte TX do módulo preexistente para a API nova, e adicionar o RX. É uma quantidade respeitável de trabalho, e a API nova é bastante diferente da velha, aí teria de ver se é possível manter a API MicroPython compatível.
A API nova é indiscutivelmente melhor, principalmente em RX. Porém ainda tem arestas, que estão sendo resolvidas (threads relevantes aqui e aqui). Esses problemas podem ser contornados mexendo direto no código do ESP-IDF, o que é viável para um fabricante de dispositivos, mas o MicroPython tem de ser baseado num ESP-IDF padrão.
Teria de analisar primeiro se essas arestas afetam o RMT TX. Se não afetarem, não perdemos funcionalidade preexistente. Basta documentar que o RX possui limitações por culpa da Espressif.
Outra alternativa é reescrever o RMT RX usando a API de legado, para integrá-lo com o módulo preexistente. É um caminho seguro, mas por outro lado parece trabalho jogado fora escrever algo novo baseado numa API do ESP-IDF 4.x (que o MicroPython nem sequer suporta mais). Também há uma carência de bons exemplos de RMT RX usando essa API de legado — muito embora o exemplo da Espressif está um pouco mais fácil de entender, agora que estou tarimbado.
De um jeito ou de outro, é um bocado de trabalho. Decidi ir pelo primeiro caminho, portando o módulo RMT atual (apenas TX) para a API nova. Aqui está o PR e vamos ver quando (ou se) ele vai ser aceito.