Red Mosquitto: Implemente um sensor de ruído com um cliente MQTT em um ESP32
Jorge D. Ortiz-Fuentes25 min read • Published Sep 17, 2024 • Updated Sep 17, 2024
APLICATIVO COMPLETO
Avalie esse Tutorial
Bem-vindo a mais um artigo da série "Aventuras em IoT". Até agora, definimos um projeto de ponta a ponta, escrevemos o microcódigo para uma placa Raspberry Pi Pico MCU para medir a temperatura e enviar o valor via desatualização por meio do sistema de banco de dados estação de coleta que conseguiu ler os dados BLE. Se ainda não teve tempo, leia-os ou assista aos vídeos.
Neste artigo, vamos escrever o microcódigo para uma placa diferente: uma cifrão32-C6-DevKitC-1. As diretorias32 são muito populares entre a comunidade faça você mesmo e para IoT em geral. O criador dessas placas, Espressif, está se esforçando bastante para oferecer suporte ao Rust como uma linguagem de desenvolvimento de primeira classe para elas. Estou agradecido por isso e aproveitarei as ferramentas que eles criaram para nós.
Podemos escrever código para o32 ESP que se comunica com o bare metal, também conhecido como núcleo, ou usar um sistema operacional que nos permita aproveitar ao máximo os recursos fornecidos pela biblioteca std. O ESP-IDF –ou seja, ESPressif IoT Development Framework– foi criado para simplificar esse desenvolvimento e não está disponível apenas em C/C++, mas também em Rust, que usaremos no restante deste artigo. Usando o ESP-IDF por meio das caixas correspondentes, podemos usar threads, mutexes e outras primitivas de sincronização, coleções, geração de números aleatórios, soquetes, etc. Minha placa é uma6 ESP32-C que usa uma arquitetura RISC-V. Ele não possui nenhum sensor embutido, mas possui um LED RGB e é capaz de se comunicar sem fio com o resto do mundo de várias maneiras: WiFi, Bluetooth LE, Zigbee e Thread. Vamos ver como podemos usar esses recursos.
Em 9janeiro de 2024 – ou seja, alguns dias depois de começar a preparar este tutorial –
embedded-hal
v1.0 foi lançado. Ele fornece uma abstração para criar drivers independentes do MCU. Isso é muito útil para nós, desenvolvedores, pois nos permite desenvolver e manter o driver uma vez e usá-lo para as diversas Placas MCU que honram essa abstração.Este kit de placa de desenvolvimento possui um LED neopixel – ou seja, um LED RGB controlado por um WS2812 – que usaremos para nossa iteração "Hello World!" e depois para informar o usuário sobre o estado do dispositivo. O WS2812 requer o envio de sequências de altas e baixas tensões que usam a duração desses valores altos e baixos para especificar os bits que definem os componentes de cor RGB do LED. O ESP32 possui um transceptor de controle remoto (RMT) que foi concebido como um transceptor infravermelho, mas pode ser reaproveitado para gerar os sinais necessários para o protocolo serial de linha única usado pelo WS1812. Nem o RMT nem os temporizadores estão disponíveis na versão recém-lançada do
embedded-hal
, mas o ESP-IDF fornecido pelo Expressif implementa a abstraçãoembedded-hal
completa e o driver WS2812 usa as abstrações disponíveis.Existem algumas ferramentas que você precisará instalar em seu computador para poder acompanhar, compilar e instalar o firmware em sua placa. Eu os instalei no meu computador, mas antes de gastar tempo com essa configuração, considere usar o contêiner fornecido pelo Espressif se preferir essa opção.
A primeira coisa que pode ser diferente para você é que precisamos da versão de ponta da cadeia de ferramentas Rust. Estaremos usando a versão noturna dele:
1 rustup toolchain install nightly --component rust-src
Quanto às ferramentas, é possível que você já tenha algumas delas em seu computador, mas verifique novamente se instalou todas elas:
- Git (no macOS instalado com Code)
- Algumas ferramentas para ajudar no processo de construção (
brew install cmake ninja dfu-util python3
–Funciona no macOS, mas se você usar um sistema operacional diferente,confira a lista aqui) - Uma ferramenta para encaminhar argumentos do vinculador para o vinculador real (
cargo install ldproxy
) - Um utilitário para gravar o firmware na placa (
cargo install espflash
) - Uma ferramenta usada para produzir um novo projeto a partir de um modelo (
cargo install cargo-generate
)
Podemos então criar um projeto usando o modelo para
stdlib
projetos (esp-idf-template
):1 cargo generate esp-rs/esp-idf-template cargo
E preenchemos estes dados:
- Nome do projeto: mosquito-bzzz
- MCU para o alvo: esp32c6
- Configurar opções avançadas de modelo: false
cargo b
produz a compilação. O alvo é riscv32imac-esp-espidf
(arquitetura SISC-V com suporte para atômicos), então o binário é gerado em target/riscv32imac-esp-espidf/debug/mosquitto-bzzz
. E pode ser executado no dispositivo usando este comando:1 espflash flash target/riscv32imac-esp-espidf/debug/mosquitto-bzzz --monitor
E no final do registro de saída, você pode encontrar estas linhas:
1 I (358) app_start: Starting scheduler on CPU0 2 I (362) main_task: Started on CPU0 3 I (362) main_task: Calling app_main() 4 I (362) mosquitto_bzzz: Hello, world! 5 I (372) main_task: Returned from app_main()
Vamos entender o projeto que foi criado para que possamos aproveitar todas as peças:
- Cargo.toml: É o arquivo de configuração principal do projeto. Além do que um
cargo new
comum faria, vamos ver o seguinte:- Define algumas funcionalidades disponíveis que modificam a configuração de algumas das dependências.
- Ele inclui algumas dependências: uma para a API de registro e outra para usar o ESP-IDF.
- Ele adiciona uma dependência de compilação que fornece utilitários para criar aplicativos para sistemas embarcados.
- Ele ajusta as configurações de perfil que modificam algumas opções do compilador, nível de otimização e símbolos de depuração, para depuração e versão.
- build.rs: um script de compilação que não pertence ao aplicativo, mas é executado como parte do processo de compilação.
- rust-toolchain.toml: Um arquivo de configuração para impor o uso do conjunto de ferramentas noturno, bem como uma cópia local docódigo-fonte da biblioteca padrão Rust.
- sdkconfig.defaults: Um arquivo com alguns parâmetros de configuração para o esp-idf.
- .cargo/config.toml: um arquivo de configuração para o Cargo, no qual temos a arquitetura, as ferramentas e os sinalizadores instáveis do compilador usado no processo de compilação e as variáveis de ambiente usadas no processo.
- src/main.rs: A semente para o nosso código com o esqueleto mínimo.
A ideia é criar um firmware semelhante ao que escrevemos para o Raspberry Pi Pico, mas expondo os dados do sensor usando MQTT em vez de Bluetooth Low Energy. Isso significa que temos de nos conectar ao WiFi, depois ao corretor MQTT e começar a publicar os dados. Usaremos o LED RGB para mostrar o status do nosso sensor e usaremos um sensor de som para obter os dados desejados.
Fazer um LED piscar é considerado o " hello world" da programação incorporada. Podemos ir um pouco além e usar cores em vez de apenas piscar.
- De acordo com a documentação da placa, o Led é controlado pelo pino gpio8 . Podemos obter acesso a esse pino usando o módulo
Peripherals
do esp-idf-svc, que expõe o hl adicionandouse esp_idf_svc::hal::peripherals::Peripherals;
:1 let peripherals = Peripherals::take().expect("Unable to access device peripherals"); 2 let led_pin = peripherals.pins.gpio8; - Também usando o singleton Periféricos, podemos acessar o canal RMT que produzirá o sinal de forma de onda desejado necessário para definir cada um dos três componentes de cor do LED:
1 let rmt_channel = peripherals.rmt.channel0; - Poderíamos fazer a codificação de cores RGB manualmente, mas há uma caixa que nos ajudará a conversar com o controlador WS2812 (neopixel) integrado que aciona o LED RGB. A
smart-leds
de criação poderia ser usada em cima dela se tivéssemos vários LEDs, mas não precisamos dela para esta placa.1 cargo add ws2812-esp32-rmt-driver - Criamos uma instância que se comunica com o WS2812 no pino 8 e usa o Transceiver de Controle Remoto - também conhecido como RMT - secundário no canal 0. Adicionamos o símbolo
use ws2812_esp32_rmt_driver::Ws2812Esp32RmtDriver;
e:1 let mut neopixel = 2 Ws2812Esp32RmtDriver::new(rmt_channel, led_pin).expect("Unable to talk to ws2812"); - Em seguida, definimos os dados de um ponto e os escrevemos com a instância do driver para que seja usado no Led. É importante não apenas importar o tipo para a cor do 24BI, mas também obter o traço com
use ws2812_esp32_rmt_driver::driver::color::{LedPixelColor,LedPixelColorGrb24};
:1 let color_1 = LedPixelColorGrb24::new_with_rgb(255, 255, 0); 2 neopixel 3 .write_blocking(color_1.as_ref().iter().cloned()) 4 .expect("Error writing to neopixel"); - Nesse momento, você pode executá-lo com
cargo r
e esperar que o LED fique aceso com a cor amarela. - Vamos adicionar um loop e algumas mudanças para completar nosso " hello world. " Primeiro, definimos uma segunda cor:
1 let color_2 = LedPixelColorGrb24::new_with_rgb(255, 0, 255); - Em seguida, adicionamos um loop no final, onde alternamos entre essas duas cores:
1 loop { 2 neopixel 3 .write_blocking(color_1.as_ref().iter().cloned()) 4 .expect("Error writing to neopixel"); 5 neopixel 6 .write_blocking(color_2.as_ref().iter().cloned()) 7 .expect("Error writing to neopixel"); 8 } - Se não introduzirmos nenhum atraso, não poderemos perceber as cores mudando, por isso adicionamos
use std::{time::Duration, thread};
e aguardamos meio segundo antes de cada alteração:1 neopixel 2 .write_blocking(color_1.as_ref().iter().cloned()) 3 .expect("Error writing to neopixel"); 4 thread::sleep(Duration::from_millis(500)); 5 neopixel 6 .write_blocking(color_2.as_ref().iter().cloned()) 7 .expect("Error writing to neopixel"); 8 thread::sleep(Duration::from_millis(500)); - Corremos e observamos o LED mudando de cor de roxo para amarelo e vice-versa a cada meio segundo.
Vamos encapsular o uso do Led em seu próprio thread. Esse thread precisa estar ciente de quaisquer alterações no status do dispositivo e usar o atual para decidir como usar o Led adequadamente.
- Primeiro, precisaremos de um enum com todos os estados possíveis. Inicialmente, ele conterá uma variante para nenhum erro, uma variante para erro de WiFi e outra para erro MQTT:
1 enum DeviceStatus { 2 Ok, 3 WifiError, 4 MqttError, 5 } - E podemos adicionar uma implementação para converter de inteiros sem sinal de oito bits em uma variante desse enum:
1 impl TryFrom<u8> for DeviceStatus { 2 type Error = &'static str; 3 4 fn try_from(value: u8) -> Result<Self, Self::Error> { 5 match value { 6 0u8 => Ok(DeviceStatus::Ok), 7 1u8 => Ok(DeviceStatus::WifiError), 8 2u8 => Ok(DeviceStatus::MqttError), 9 _ => Err("Unknown status"), 10 } 11 } 12 } - Gostaríamos de usar as variantes
DeviceStatus
por nome quando um número for necessário. Conseguimos a conversão inversa adicionando uma anotação ao enum:1 2 enum DeviceStatus { - Em seguida, farei algo que será considerado ingênua por qualquer pessoa que tenha desenvolvido algo em Rust, além do mais simples " hello world! " No entanto, quero destacar uma das vantagens de usar o Rust, em vez da maioria das outras linguagens, para escrever firmware (e software em geral). Estou para definir uma variável na função principal que manterá o status atual do dispositivo e o compartilhará entre os threads.
1 let mut status = DeviceStatus::Ok as u8; - Vamos definir duas threads. A primeira destina-se a relatar ao usuário o status do dispositivo. A segunda é necessária apenas para fins de teste e a substituiremos por alguma funcionalidade real em breve. Usaremos sequências de cores no LED para relatar o status do sensor. Vamos começar definindo cada uma das etapas dessas sequências de cores:
1 struct ColorStep { 2 red: u8, 3 green: u8, 4 blue: u8, 5 duration: u64, 6 } - Também definimos um construtor como uma função associada para nossa própria conveniência:
1 impl ColorStep { 2 fn new(red: u8, green: u8, blue: u8, duration: u64) -> Self { 3 ColorStep { 4 red, 5 green, 6 blue, 7 duration, 8 } 9 } 10 } - Podemos então usar essas etapas para transformar cada status em uma sequência diferente que podemos exibir no LED:
1 impl DeviceStatus { 2 fn light_sequence(&self) -> Vec<ColorStep> { 3 match self { 4 DeviceStatus::Ok => vec![ColorStep::new(0, 255, 0, 500), ColorStep::new(0, 0, 0, 500)], 5 DeviceStatus::WifiError => { 6 vec![ColorStep::new(255, 0, 0, 200), ColorStep::new(0, 0, 0, 100)] 7 } 8 DeviceStatus::MqttError => vec![ 9 ColorStep::new(255, 0, 255, 100), 10 ColorStep::new(0, 0, 0, 300), 11 ], 12 } 13 } 14 } - Iniciamos o thread inicializando o WS2812 que controla o LED:
1 use esp_idf_svc::hal::{ 2 gpio::OutputPin, 3 peripheral::Peripheral, 4 rmt::RmtChannel, 5 }; 6 7 fn report_status( 8 status: &u8, 9 rmt_channel: impl Peripheral<P = impl RmtChannel>, 10 led_pin: impl Peripheral<P = impl OutputPin>, 11 ) -> ! { 12 let mut neopixel = 13 Ws2812Esp32RmtDriver::new(rmt_channel, led_pin).expect("Unable to talk to ws2812"); 14 loop {} 15 } - Podemos acompanhar o status anterior e a sequência atual, para que não tenhamos que regenerá-la depois de exibi-la uma vez. Isso não é necessário, mas é mais eficiente:
1 let mut prev_status = DeviceStatus::WifiError; // Anything but Ok 2 let mut sequence: Vec<ColorStep> = vec![]; - Em seguida, entramos em um loop infinito, no qual atualizamos o status, se ele mudou, e a sequência de acordo. De qualquer forma, usamos cada uma das etapas da sequência para exibi-la no LED:
1 loop { 2 if let Ok(status) = DeviceStatus::try_from(*status) { 3 if status != prev_status { 4 prev_status = status; 5 sequence = status.light_sequence(); 6 } 7 for step in sequence.iter() { 8 let color = LedPixelColorGrb24::new_with_rgb(step.red, step.green, step.blue); 9 neopixel 10 .write_blocking(color.as_ref().iter().cloned()) 11 .expect("Error writing to neopixel"); 12 thread::sleep(Duration::from_millis(step.duration)); 13 } 14 } 15 } - Observe que o status não pode ser comparado até que implementemos
PartialEq
, e atribuí-lo requer Clone e Copy, então os derivamos:1 2 enum DeviceStatus { - Agora, vamos implementar a função que é executada no outro thread. Esta função alterará o status a cada 10 segundos. Como isso é para testar a capacidade de relatório, não faremos nada sofisticado para alterar o status, apenas passando de um status para o próximo e de volta ao início:
1 fn change_status(status: &mut u8) -> ! { 2 loop { 3 thread::sleep(Duration::from_secs(10)); 4 if let Ok(current) = DeviceStatus::try_from(*status) { 5 match current { 6 DeviceStatus::Ok => *status = DeviceStatus::WifiError as u8, 7 DeviceStatus::WifiError => *status = DeviceStatus::MqttError as u8, 8 DeviceStatus::MqttError => *status = DeviceStatus::Ok as u8, 9 } 10 } 11 } 12 } - Com as duas funções em vigor, só precisamos gerar dois threads, um com cada uma delas. Usaremos um escopo de thread que se encarregará de unir os threads que gerarmos:
1 thread::scope(|scope| { 2 scope.spawn(|| report_status(&status, rmt_channel, led_pin)); 3 scope.spawn(|| change_status(&mut status)); 4 }); - A compilação desse código resultará em erros. É a bênção/maldição do verificador de empréstimos, que é capaz de descobrir que estamos compartilhando memória de forma insegura. O status pode ser alterado em um thread enquanto está sendo lido pelo outro. Poderíamos usar um mutex, como fizemos no código C++ anterior, e envolvê-lo em um
Arc
para poder usar uma referência em cada thread, mas há uma maneira mais fácil de atingir o mesmo objetivo: podemos usar um tipo atômico. (use std::sync::atomic::AtomicU8;
)1 let status = &AtomicU8::new(0u8); - Modificamos
report_status()
para usar a referência ao tipo atômico e adicionamosuse std::sync::atomic::Ordering::Relaxed;
:1 fn report_status( 2 status: &AtomicU8, 3 rmt_channel: impl Peripheral<P = impl RmtChannel>, 4 led_pin: impl Peripheral<P = impl OutputPin>, 5 ) -> ! { 6 let mut neopixel = 7 Ws2812Esp32RmtDriver::new(rmt_channel, led_pin).expect("Unable to talk to ws2812"); 8 let mut prev_status = DeviceStatus::WifiError; // Anything but Ok 9 let mut sequence: Vec<ColorStep> = vec![]; 10 loop { 11 if let Ok(status) = DeviceStatus::try_from(status.load(Relaxed)) { - E
change_status()
. Observe que, nesse caso, graças à mutabilidade interna, não precisamos de uma referência mutável, mas de uma referência regular. Além disso, precisamos especificar as garantias em termos de como as várias operações serão ordenadas. Como não temos nenhuma outra operação atômica no código, podemos usar o nível mais fraco, ou seja,Relaxed
:1 fn change_status(status: &AtomicU8) -> ! { 2 loop { 3 thread::sleep(Duration::from_secs(10)); 4 if let Ok(current) = DeviceStatus::try_from(status.load(Relaxed)) { 5 match current { 6 DeviceStatus::Ok => status.store(DeviceStatus::WifiError as u8, Relaxed), 7 DeviceStatus::WifiError => status.store(DeviceStatus::MqttError as u8, Relaxed), 8 DeviceStatus::MqttError => status.store(DeviceStatus::Ok as u8, Relaxed), 9 } 10 } 11 } 12 } - Finalmente, temos que alterar as linhas nas quais geramos os threads para refletir as mudanças que introduzimos:
1 scope.spawn(|| report_status(status, rmt_channel, led_pin)); 2 scope.spawn(|| change_status(status)); - Você pode usar
cargo r
para compilar o código e executá-lo em seu painel. As semáforos devem exibir as sequências, que devem mudar a cada 10 segundos.
É hora de interagir com um sensor de temperatura... Entretenimento. Desta vez, vamos usar um sensor de som. Não há mais medições de temperatura neste projeto. Promise.
O sensor que vou usar é um01 de som OSEPP que afirma ser "o sensor perfeito para detectar variações ambientais no ruído". Ele suporta uma tensão de entrada de 3V a 5V e fornece um sinal analógico. Vamos conectar o sinal ao pino 0 do GPIO, que também é o pino do primeiro canal do conversor analógico-digital (ADC1_CH0). Os outros dois pinos são conectados a 5V e GND (+ e -, respectivamente). Você não precisa usar este sensor específico. Existem muitas outras opções no mercado. Alguns deles possuem pinos para saída digital, em vez de apenas analógicos como neste. Alguns sensores também possuem um potenciômetro que permite ajustar a sensibilidade do microfone.
- Vamos executar esta tarefa em uma nova função:
1 fn read_noise_level() -> ! { 2 } - Queremos usar o ADC no pino ao qual conectamos o sinal. Podemos obter acesso ao ADC1 usando o singleton
peripherals
na função principal.1 let adc = peripherals.adc1; - E também ao pino que receberá o sinal do sensor:
1 let adc_pin = peripherals.pins.gpio0; - Modificamos a assinatura de nossa nova função para aceitar os parâmetros de que precisamos:
1 fn read_noise_level<GPIO>(adc1: ADC1, adc1_pin: GPIO) -> ! 2 where 3 GPIO: ADCPin<Adc = ADC1>, - Agora, usamos esses dois parâmetros para anexar um driver que pode ser usado para ler a partir do ADC. Observe que o
AdcDriver
precisa de uma configuração, que criamos com o valor padrão. Além disso,AdcChannelDriver
requer um parâmetro const genérico que é usado para definir o nível de atenuação. Inicialmente, usaria atenuação máxima para ter mais sensibilidade no micro, mas podemos alterá-la mais tarde, se necessário. Adicionamosuse esp_idf_svc::hal::adc::{attenuation, AdcChannelDriver};
:1 let mut adc = 2 AdcDriver::new(adc1, &adc::config::Config::default()).expect("Unable to initialze ADC1"); 3 let mut adc_channel_drv: AdcChannelDriver<{ attenuation::DB_11 }, _> = 4 AdcChannelDriver::new(adc1_pin).expect("Unable to access ADC1 channel 0"); - Com as peças necessárias posicionadas, podemos usar o
adc_channel
para obter amostras em um loop infinito. Um atraso de 10ms significa que a amostragem será a ~100hz:1 loop { 2 thread::sleep(Duration::from_millis(10)); 3 println!("ADC value: {:?}", adc.read(&mut adc_channel)); 4 } - Por último, geramos um tópico com esta função no mesmo escopo que usámos antes:
1 scope.spawn(|| read_noise_level(adc, adc_pin));
Para obter uma estimativa do nível de ruído, vou calcular a raiz quadrada média (RMS) de um buffer de 50ms, ou seja, cinco amostras em nossa taxa de amostragem atual. Eu sei que não é exatamente assim que os decibéis são medidos, mas será suficiente para nós e para os dados que queremos coletar.
- Vamos começar criando esse buffer onde colocaremos as amostras:
1 const LEN: usize = 5; 2 let mut sample_buffer = [0u16; LEN]; - Dentro do loop infinito, teremos um loop for que passa pelo buffer:
1 for i in 0..LEN { 2 } - Modificamos a amostragem que fizemos antes, de modo que um valor zero seja usado se o ADC não conseguir obter uma amostra:
1 thread::sleep(Duration::from_millis(10)); 2 if let Ok(sample) = adc.read(&mut adc_pin) { 3 sample_buffer[i] = sample; 4 } else { 5 sample_buffer[i] = 0u16; 6 } - Antes de começar com as iterações do loop for, vamos definir uma variável para manter a adição dos quadrados das amostras:
1 let mut sum = 0.0f32; - E cada amostra é elevada ao quadrado e adicionada à soma. Poderíamos fazer a conversão em floats após o quadrado, mas então, o valor do quadrado pode não caber em um u16:
1 sum += (sample as f32) * (sample as f32); - E calculamos os decibéis (ou algo próximo a isso) após o loop for:
1 let d_b = 20.0f32 * (sum / LEN as f32).sqrt().log10(); 2 println!( 3 "ADC values: {:?}, sum: {}, and dB: {} ", 4 sample_buffer, sum, d_b 5 ); - Compilamos e executamos com
cargo r
e devemos obter uma saída semelhante a:1 ADC values: [0, 0, 0, 0, 0], sum: 0, and dB: -inf 2 ADC values: [0, 0, 0, 3, 0], sum: 9, and dB: 2.5527248 3 ADC values: [0, 0, 0, 11, 0], sum: 121, and dB: 13.838154 4 ADC values: [8, 0, 38, 0, 102], sum: 11912, and dB: 33.770145 5 ADC values: [64, 23, 0, 8, 26], sum: 5365, and dB: 30.305998 6 ADC values: [0, 8, 41, 0, 87], sum: 9314, and dB: 32.70166 7 ADC values: [137, 0, 79, 673, 0], sum: 477939, and dB: 49.804024 8 ADC values: [747, 0, 747, 504, 26], sum: 1370710, and dB: 54.379753 9 ADC values: [240, 0, 111, 55, 26], sum: 73622, and dB: 41.680374 10 ADC values: [8, 26, 26, 58, 96], sum: 13996, and dB: 34.470337
Quando escrevemos nosso firmware anterior, usamos o Bluetooth Low Energy para disponibilizar os dados do sensor para o resto do mundo. Foi uma experiência interessante, mas teve algumas limitações. Algumas dessas limitações foram introduzidas pelo hardware que estávamos usando, como o fato de estarmos recebendo algumas interferências no sinal Bluetooth das comunicações WiFi no Raspberry Pi. Mas outros são inerentes à tecnologia Bluetooth, como a distância máxima entre o sensor e a estação de coleta.
Para este firmware, decidimos adotar uma abordagem diferente. Usaremos Wi-Fi para as comunicações dos sensores para a estação de coleta. O WiFi nos permitirá distribuir os sensores por uma área muito maior, especialmente se tivermos vários pontos de acesso. No entanto, isso tem um preço: os sensores consumirão mais energia e suas pilhas durarão menos.
Usar WiFi praticamente implica que nossas comunicações serão baseadas em TCP/IP. E isso abre uma ampla gama de possibilidades, que podemos resumir nesta lista em ordem crescente de probabilidade:
- Implemente um protocolo TCP ou UDP personalizado.
- Use um protocolo existente que seja comumente usado para escrever API. Existem outras opções, mas HTTP é o principal aqui.
- Use um protocolo existente que seja mais personalizado com a finalidade de enviar dados de eventos que contenham valores.
Criar um protocolo personalizado é caro, demorado e sujeito a erros, especialmente sem experiência anterior. É provavelmente a pior ideia para uma prova de conceito, a menos que você tenha um requisito muito específico que não possa ser realizado de outra forma.
O HTTP vem à mente como uma ótima solução para trocar dados. As REST API são um exemplo disso. No entanto, ele tem algumas limitações, como o fluxo unidirecional de dados, a sobrecarga — tanto em termos do protocolo em si quanto no uso de uma nova conexão para cada nova solicitação — e até mesmo a falta de provisão para notificar clientes selecionados quando os dados nos quais eles estão interessados mudam.
Se quisermos usar um protocolo que foi projetado para isso, o MQTT é a escolha natural. Além de superar as limitações do HTTP para esse tipo de comunicação, ele foi testado em campo com muitos sensores que mudam com muita frequência e, pronto para uso, pode fazer coisas sofisticadas, como armazenar o último valor bom conhecido ou ter comandos de cliente específicos que permitem receber atualizações sobre valores específicos ou um conjunto deles. O MQTT foi projetado como um protocolo para publicação/assinatura (pub/sub) nos cenários comuns da IoT. O servidor que controla todas as comunicações é comumente chamado de broker, e nossos sensores serão seus clientes.
Agora que entendemos melhor por que estamos usando o MQTT, vamos nos conectar ao nosso corretor e enviar os dados obtidos do nosso sensor para que sejam publicados lá.
No entanto, antes de poder fazer isso, precisamos nos conectar ao WiFi.
É importante ter em mente que a placa que estamos usando tem suporte para WiFi, mas apenas no 2.4GHz. Ele não poderá se conectar ao seu roteador usando a banda de 5GHz, não importa o quão gentilmente você peça para fazê-lo.
Além disso, a menos que você seja um milionário rico e tenha uma boa ilha para se concentrar em acompanhar esse conteúdo, seria sensato usar uma senha bastante forte para manter usuários não autorizados fora de sua rede.
- Vamos começar definindo alguma estrutura para manter os dados de autenticação para acessar a rede:
1 struct Configuration { 2 wifi_ssid: &'static str, 3 wifi_password: &'static str, 4 } - Poderíamos definir os valores no código, mas gosto mais da abordagem sugerida pela Ferrous Systems. Usaremos a caixa
toml_cfg
. Teremos valores padrão (inúteis neste caso, a não ser para obter um erro) que serão substituídos pelo uso de um arquivo toml com os valores desejados. Primeiro, o mais importante: Vamos adicionar a caixa:1 cargo add toml-cfg - Vamos agora anotar a estrutura com algumas macros:
1 2 struct Configuration { 3 4 wifi_ssid: &'static str, 5 6 wifi_password: &'static str, 7 } - Agora podemos adicionar um arquivo
cfg.toml
com os valoresreais desses parâmetros.1 [mosquitto-bzzz] 2 wifi_ssid = "ThisAintEither" 3 wifi_password = "NorIsThisMyPassword" - Por favor, lembre-se de adicionar esse nome de arquivo à configuração do
.gitignore
, para que ele não acabe em nosso repositório com nossos segredos mais caros:1 echo "cfg.toml" >> .gitignore - O código para se conectar ao WiFi é um pouco entediante. Faz sentido fazer isso em uma função diferente:
1 fn connect_to_wifi(ssid: &str, passwd: &str) {} - Essa função deve ter uma maneira de nos informar se houve um problema, mas queremos simplificar o tratamento de erros, por isso adicionamos a caixa
anyhow
:1 cargo add anyhow - Agora podemos usar o tipo
Result
fornecido por de qualquer forma (import anyhow::Result;
). Dessa forma, não precisamos nos cansar de criar e usar um tipo de erro personalizado.1 fn connect_to_wifi(ssid: &str, passwd: &str) -> Result<()> { 2 Ok(()) 3 } - Se a função não obtiver um SSID, ela não conseguirá se conectar ao WiFi, portanto, é melhor parar aqui e retornar um erro (
import anyhow::bail;
):1 if ssid.is_empty() { 2 bail!("No SSID defined"); 3 } - Se a função receber uma senha, assumiremos que a autenticação usa WPA2. Caso contrário, nenhuma autenticação será usada (
use esp_idf_svc::wifi::AuthMethod;
):1 let auth_method = if passwd.is_empty() { 2 AuthMethod::None 3 } else { 4 AuthMethod::WPA2Personal 5 }; - Vamos precisar de uma instância do loop do sistema para manter a conexão com o WiFi vivo e funcionando, então acessamos o singleton do loop de eventos do sistema (
use esp_idf_svc::eventloop::EspSystemEventLoop;
euse anyhow::Context
).1 let sys_loop = EspSystemEventLoop::take().context("Unable to access system event loop.")?; - Embora não seja necessário, o esp32 armazena alguns dados de conexões de rede anteriores no armazenamento não-volátil, portanto, obter acesso a ele simplificará e acelerará o processo de conexão (
use esp_idf_svc::nvs::EspDefaultNvsPartition;
).1 let nvs = EspDefaultNvsPartition::take().context("Unable to access default NVS partition")?; - A conexão com o WiFi é feita por meio do modem, que pode ser acessado por meio dos periféricos da placa. Passamos os periféricos, obtemos o modem e o usamos para primeiro envolvê-lo com um driver WiFi e, em seguida, obter uma instância que usaremos para gerenciar a conexão WiFi (
use esp_idf_svc::wifi::{EspWifi, BlockingWifi};
):1 fn connect_to_wifi(ssid: &str, passwd: &str, 2 modem: impl Peripheral<P = modem::Modem> + 'static, 3 ) -> Result<()> { 4 // Auth checks here and sys_loop ... 5 let mut esp_wifi = EspWifi::new(modem, sys_loop.clone(), Some(nvs))?; 6 let mut wifi = BlockingWifi::wrap(&mut esp_wifi, sys_loop)?; - Em seguida, adicionamos uma configuração ao WiFi (
use esp_idf_svc::wifi;
):1 wifi.set_configuration(&mut wifi::Configuration::Client( 2 wifi::ClientConfiguration { 3 ssid: ssid 4 .try_into() 5 .map_err(|_| anyhow::Error::msg("Unable to use SSID"))?, 6 password: passwd 7 .try_into() 8 .map_err(|_| anyhow::Error::msg("Unable to use Password"))?, 9 auth_method, 10 ..Default::default() 11 }, 12 ))?; - Com a configuração instalada, iniciamos o rádio WiFi, conectamos à rede WiFi e esperamos que a conexão seja concluída. Qualquer erro aparecerá:
1 wifi.start()?; 2 wifi.connect()?; 3 wifi.wait_netif_up()?; - É útil neste ponto exibir os dados da conexão.
1 let ip_info = wifi.wifi().sta_netif().get_ip_info()?; 2 log::info!("DHCP info: {:?}", ip_info); - Também queremos retornar a variável que mantém a conexão. Caso contrário, a conexão será fechada quando sair do escopo no final desta função. Mudamos a assinatura para poder fazer isso:
1 ) -> Result<Box<EspWifi<'static>>> { - E retorne esse valor:
1 Ok(Box::new(wifi_driver)) - Vamos inicializar a conexão com o WiFi a partir de nossa função para ler o ruído, então vamos adicionar o modem como parâmetro:
1 fn read_noise_level<GPIO>( 2 adc1: ADC1, 3 adc1_pin: GPIO, 4 modem: impl Peripheral<P = modem::Modem> + 'static, 5 ) -> ! - Este novo parâmetro deve ser inicializado na função principal:
1 let modem = peripherals.modem; - E passamos para a função quando geramos o tópico:
1 scope.spawn(|| read_noise_level(adc, adc_pin, modem)); - Dentro da função em que planejamos usar esses parâmetros, recuperamos a configuração. A constante
CONFIGURATION
é gerada automaticamente pela caixacfg-toml
usando o tipo da estrutura:1 let app_config = CONFIGURATION; - Em seguida, tentamos nos conectar ao WiFi usando esses parâmetros:
1 let _wifi = match connect_to_wifi(app_config.wifi_ssid, app_config.wifi_password, modem) { 2 Ok(wifi) => wifi, 3 Err(err) => { 4 5 } 6 }; - E, ao lidar com o caso de erro, alteramos o valor do status:
1 log::error!("Connect to WiFi: {}", err); 2 status.store(DeviceStatus::WifiError as u8, Relaxed); - Esta função não aceita o estado como argumento, então o adicionamos à sua assinatura:
1 fn read_noise_level<GPIO>( 2 status: &AtomicU8, - Esse argumento é fornecido quando o tópico é gerado:
1 scope.spawn(|| read_noise_level(status, adc, adc_pin, modem)); - Não queremos mais que o status seja alterado sequencialmente, então removemos esse thread e a função que estava implementando essa alteração.
- Executamos este código com
cargo r
para verificar se podemos nos conectar à rede. No entanto, esta versão vai falhar. } Nossa função excederá o tamanho padrão da pilha de um thread que, por padrão, é 4Kbytes. - Podemos usar um construtor de thread, em vez da função
spawn
, para alterar o tamanho da pilha:1 thread::Builder::new() 2 .stack_size(6144) 3 .spawn_scoped(scope, || read_noise_level(status, adc, adc_pin, modem)) 4 .unwrap(); - Depois de realizar essa alteração, executamos novamente
cargo r
e ela deve funcionar conforme o esperado.
A próxima etapa após a conexão com o WiFi é conectar-se ao corretor MQTT como cliente, mas ainda não temos um corretor MQTT. Nesta seção, mostrarei como instalar o Mosquitto, que é um projeto de código aberto da Eclipse Foundation.
- Para esta seção, precisamos ter um corretor MQTT. No meu caso, estarei instalando o Mosquitto, que implementa versões 3.1.1 e 5.0 do protocolo MQTT. Ele funcionará no mesmo Raspberry Pi que estou usando como estação de coleta.
1 sudo apt-get update && sudo apt-get upgrade 2 sudo apt-get install -y {mosquitto,mosquitto-clients,mosquitto-dev} 3 sudo systemctl enable mosquitto.service - Modificamos a configuração do Mosquitto para permitir que os clientes se conectem de fora do host local. Precisamos de algumas credenciais e de uma configuração que imponha a autenticação:
1 sudo mosquitto_passwd -c -b /etc/mosquitto/passwd soundsensor "Zap\!Pow\!Bam\!Kapow\!" 2 sudo sh -c 'echo "listener 1883\nallow_anonymous false\npassword_file /etc/mosquitto/passwd" > /etc/mosquitto/conf.d/remote_access.conf' 3 sudo systemctl restart mosquitto - Vamos testar se podemos assinar e publicar em um tópico. A convenção de nomenclatura tende a usar apenas letras minúsculas, números e traços e reserva os traços para separar os tópicos hierarquicamente. Em um terminal, assine o
testTopic
:1 mosquitto_sub -t test/topic -u soundsensor -P "Zap\!Pow\!Bam\!Kapow\!" - E em outro terminal, publique algo nele:
1 mosquitto_pub -d -t test/topic -m "Hola caracola" -u soundsensor -P "Zap\!Pow\!Bam\!Kapow\!" - Você deve ver a mensagem que escrevemos no segundo terminal aparecer no primeiro. Isso significa que o Mosquitto está funcionando conforme o esperado.
Com o corretor MQTT instalado e pronto, podemos escrever o código para conectar nosso sensor a ele como um cliente MQTT e publicar seus dados.
- Vamos precisar das credenciais que acabamos de criar para publicar dados no corretor MQTT, então os adicionamos à estrutura
Configuration
:1 2 struct Configuration { 3 4 wifi_ssid: &'static str, 5 6 wifi_password: &'static str, 7 8 mqtt_host: &'static str, 9 10 mqtt_user: &'static str, 11 12 mqtt_password: &'static str, 13 } - Você deve se lembrar de adicionar os valores que fazem sentido ao arquivo
cfg.toml
para seu ambiente. Não espere obtê-los do meu repositório, porque solicitamos ao Git para ignorar este arquivo. No mínimo, você precisa do nome do host ou endereço IP do seu corretor MQTT. Copie o nome de usuário e senha que criamos anteriormente:1 [mosquitto-bzzz] 2 wifi_ssid = "ThisAintEither" 3 wifi_password = "NorIsThisMyPassword" 4 mqtt_host = "mqttsystem" 5 mqtt_user = "soundsensor" 6 mqtt_password = "Zap!Pow!Bam!Kapow!" - Retornando à função que criamos para ler o sensor de ruído, agora podemos inicializar um cliente MQTT após conectar ao Wi-Fi (
use mqtt::client::{EspMqttClient, MqttClientConfiguration, QoS},
:1 let mut mqtt_client = 2 EspMqttClient::new() 3 .expect("Unable to initialize MQTT client"); - O primeiro parâmetro é um URL para o servidor MQTT que incluirá o usuário e a senha, se definido:
1 let mqtt_url = if app_config.mqtt_user.is_empty() || app_config.mqtt_password.is_empty() { 2 format!("mqtt://{}/", app_config.mqtt_host) 3 } else { 4 format!( 5 "mqtt://{}:{}@{}/", 6 app_config.mqtt_user, app_config.mqtt_password, app_config.mqtt_host 7 ) 8 }; - O segundo parâmetro é a configuração. Vamos adicioná-los à criação do cliente MQTT:
1 EspMqttClient::new(&mqtt_url, &MqttClientConfiguration::default(), |_| { 2 log::info!("MQTT client callback") 3 }) - Para publicar, precisamos definir o tópico:
1 const TOPIC: &str = "home/noise sensor/01"; - E uma variável que será usada para conter a mensagem que publicaremos:
1 let mut mqtt_msg: String; - Dentro do loop, formataremos o valor do ruído porque ele é enviado como uma string:
1 mqtt_msg = format!("{}", d_b); - Publicamos este valor usando o cliente MQTT:
1 if let Ok(msg_id) = mqtt_client.publish(TOPIC, QoS::AtMostOnce, false, mqtt_msg.as_bytes()) 2 { 3 println!( 4 "MSG ID: {}, ADC values: {:?}, sum: {}, and dB: {} ", 5 msg_id, sample_buffer, sum, d_b 6 ); 7 } else { 8 println!("Unable to send MQTT msg"); 9 } - Como fizemos quando publicamos a partir da linha de comando, precisamos assinar, em um terminal independente, o tópico no qual planejamos publicar. Neste caso, começaremos com
home/noise sensor/01
. Observe que representamos uma hierarquia, ou seja, há sensores de ruído em casa e cada um dos sensores tem um identificador. Além disso, observe que os níveis da hierarquia são separados por barras e podem incluir espaços em seus nomes.1 mosquitto_sub -t "home/noise sensor/01" -u soundsensor -P "Zap\!Pow\!Bam\!Kapow\!" - Por fim, compilamos e executamos o firmware com
cargo r
e poderemos ver esses valores aparecendo no terminal inscrito no tópico.
Gostaria de terminar este firmware resolvendo um problema que não aparecerá até que tenhamos dois sensores ou mais. Nosso firmware usa um tema constante. Isso significa que dois sensores com o mesmo firmware usarão o mesmo tópico e não teremos como saber qual valor corresponde a qual sensor. Uma opção melhor é usar um identificador exclusivo que será diferente para cada placa6 ESP32-C. Podemos usar o endereço MAC para isso.
- Vamos começar criando uma função que retorne esse identificador:
1 fn get_sensor_id() -> String { 2 } - Nossa função usará uma função insegura do ESP-IDF e formatará o resultado como
String
(use esp_idf_svc::sys::{esp_base_mac_addr_get, ESP_OK};
euse std::fmt::Write
). A função que retorna o endereço MAC usa um ponteiro e, tendo sido escrita em C++, não poderia se importar menos com as regras de segurança que o código Rust deve obedecer. Essa função é considerada insegura e, como tal, o Rust exige que a usemos dentro dounsafe
escopo . É a maneira deles de nos dizer: " Aqui estão os dragões... e você sabe disso " :1 let mut mac_addr = [0u8; 8]; 2 unsafe { 3 match esp_base_mac_addr_get(mac_addr.as_mut_ptr()) { 4 ESP_OK => { 5 let sensor_id = mac_addr.iter().fold(String::new(), |mut output, b| { 6 let _ = write!(output, "{b:02x}"); 7 output 8 }); 9 log::info!("Id: {:?}", sensor_id); 10 sensor_id 11 } 12 _ => { 13 log::error!("Unable to get id."); 14 String::from("BADCAFE00BADBEEF") 15 } 16 } 17 } - Em seguida, usamos a função antes de definir o tópico e usamos seu resultado com ela:
1 let sensor_id = get_sensor_id(); 2 let topic = format!("home/noise sensor/{sensor_id}"); - E mudamos um pouco a forma como publicamos os dados para usar o tópico:
1 if let Ok(msg_id) = mqtt_client.publish(&topic, QoS::AtMostOnce, false, mqtt_msg.as_bytes()) - Também precisamos alterar a assinatura para ouvirmos todos os tópicos que começam com
home/sensor/
e ter mais um nível:1 mosquitto_sub -t "home/noise sensor/+" -u soundsensor -P "Zap\!Pow\!Bam\!Kapow\!" - Compilamos e executamos com
cargo r
e os valores começam a aparecer no terminal onde a assinatura foi iniciada.
Neste artigo, usamos o Rust para escrever o microcódigo para uma placa32-C6-DevKitC-1 do início ao fim. Embora possamos concordar que o Python foi uma abordagem mais fácil para nosso primeiro software, considero o Rust uma linguagem mais robusta, acessível e útil para esse fim.
O firmware que criamos pode informar o usuário sobre qualquer problema usando um LED RGB, medir o ruído em algo próximo o suficiente aos decibéis, conectar nossa placa ao WiFi e depois ao nosso corretor MQTT como cliente e publicar as medidas do nosso sensor de ruído. Nada mal para um único tutorial.
Até nos adiantamos e adicionamos algum código para garantir que sensores diferentes com o mesmo firmware publiquem seus valores em tópicos diferentes. E, para isso, fizemos uma breve incursão no universo do unsafe Rust e sobrevivemos ao deserto. Agora você pode ir a um bar e dizer aos seus amigos: "Eu escrevi unsafe Rust." Muito bem!
Em nosso próximo artigo, escreveremos código C++ novamente para coletar os dados do intermediário MQTT e enviá-los para nossa instância do MongoDB Atlas na nuvem. Então, prepare-se!
Principais comentários nos fóruns
Ainda não há comentários sobre este artigo.