Analog

Numa aula passada foi visto como era possível ligar sinais digitais ao ESP32. Vamos agora ver como proceder se os sinais tiverem características analógicas, ou seja, em lugar de apresentarem valores de tensão próximos de 0V e 3.3V (respetivamente nível lógico 0 e 1), poderem apresentar também tensões intermédias.

Saídas analógicas

O ESP32 possui um conversor digital/analógico (DAC) que permite gerar em dois dos seus pinos (pino 25 e 26) sinais analógicos entre 0V e 3.3V. Para isso teremos que indicar um número de 8 bits (de 0 a 255 portanto) que dará origem a uma tensão proporcional a esse valor: 0 gera uma tensão de 0V, 255 gera uma tensão de 3.3V, um valor intermédio n gera uma tensão igual a n * 3.3V/255.

O seguinte exemplo, gera no pino 25 uma sequência de tensões em função dos valores presentes numa lista de números de 8 bits:

from machine import Pin, DAC

dac = DAC(Pin(25))

values = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
          25, 50, 75, 100, 125, 150, 175, 200, 225,
          245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]

for value in values:
    print(value)
    dac.write(value)
    input("Press Enter to continue...")

A medição do valor das tensões com um voltímetro permitiu traçar o seguinte gráfico:

Neste gráfico é possível confirmar a boa linearidade das tensões geradas em função do valor de 8 bits fornecido ao DAC, sendo contudo de registar um certo offset nos valores mais próximos de 0. Com efeito, quando se fornece ao DAC um valor nulo, em lugar de 0V, a tensão gerada é de cerca de 0,1V.

As aplicações para esta funcionalidade podem por exemplo passar pela geração de formas de onda, em que periodicamente seriam geradas as amplitudes da onda que se pretende obter. De notar porém que face às limitações do MicroPython, a frequência de amostragem (o inverso do intervalo de tempo entre a geração de cada ponto na forma de onda) não consegue ser muito elevada o que limita a frequência máxima do sinal a gerar. Com efeito o teorema de Nyquist afirma que essa frequência máxima nunca pode ser superior a metade da frequência de amostragem.

Entradas analógicas

De igual modo, o ESP32 pode também ler sinais analógicos. Para tal existem 6 pinos da placa ESP32 Pico Kit (pino 32, 33, 34, 35 37 e 38) onde é possível aplicar uma tensão não superior a 3.6V (caso contrário iremos danificar o ESP32), existindo agora um conversor analógico/digital (ADC) que gera um número inteiro proporcional ao valor da tensão.

Note-se que no caso do ADC é possível definir o número de bits entre 9 e 12 (WIDTH_9BIT, WIDTH_10BIT, WIDTH_11BIT e WIDTH_12BIT), permitindo-se assim a obtenção de diversas resoluções na conversão efetuada. Por exemplo, caso se use 12 bits, as tensões serão convertidas num número de 0 a 4095 (um total de 2^12 valores possíveis) a que corresponde uma resolução na medida de cerca de 0,8mV (3,3/4096).

O ADC interno do ESP32 tem de facto uma gama máxima de conversão de 0V a 1V. Caso os sinais a medir tenham essa gama não é preciso atenuá-los pelo que o fator de atenuação a introduzir é de 0dB (ATTN_0DB). Caso os sinais apresentem gamas maiores, terão que se introduzir atenuações no sinal de entrada, dispondo o ESP32 de atenuações de 11dB, 6dB e 2,5dB (ATTN_11DB, ATTN_6DB e ATTN_2_5DB) a que correspondem gamas entre 0V e 3,6V, 2V e 1,34V respetivamente. Com efeito:

11dB = 20 * Log10( 3,6 / 1,0 )

No seguinte programa (que usa as funcionalidades ADC do MicroPython para o ESP32) todas estas combinações foram testadas sendo colocado o ADC a medir a tensão presente no pino 32, que neste exemplo vem do pino 25 onde o DAC gera uma tensão com base no exemplo anterior.

from machine import Pin, DAC, ADC

dac = DAC(Pin(25))

values = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
          25, 50, 75, 100, 125, 150, 175, 200, 225,
          245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255]

adc = ADC(Pin(32))

attens = [ADC.ATTN_0DB, ADC.ATTN_2_5DB, ADC.ATTN_6DB, ADC.ATTN_11DB]
widths = [ADC.WIDTH_9BIT, ADC.WIDTH_10BIT, ADC.WIDTH_11BIT, ADC.WIDTH_12BIT]

for atten in attens:
    adc.atten(atten)

    for width in widths:
        adc.width(width)

        for value in values:
            dac.write(value)

            value = 0
            for n in range(16):
                value = value + adc.read()
            value = value >> 4

            print(value)

        input("Press Enter to continue...")

De notar que, para minorar o ruído presente quer no sinal a medir que no processo de conversão, em lugar de se realizar apenas uma medida, são feitas 16 das quais resulta depois uma média que é apresentada.

A partir destes valores foi possível criar os seguintes gráficos:

O primeiro representa os resultados da conversão A/D usando a gama de conversão máxima com os diferentes números de bits:

O segundo representa os resultados da conversão A/D usando 12 bits com as diferentes gamas de conversão:

A análise dos gráficos permite tirar algumas conclusões:

  • A linearidade é inferior à do DAC, em especial para tensões próximas de zero ou do valor máximo.
  • Existe um offset próximo do zero, i.e. tensões até cerca de 0,1V dão como resultado de conversão ou zero ou mesmo valores errados.

Em termos de aplicação desta funcionalidade, existem muitos sensores que transformam a grandeza que medem (por exemplo a temperatura ou a aceleração) num valor de tensão que lhe é proporcional. Exemplo disso é o sensor de temperatura LM35 ou o acelerómetro ADXL326. A medição das grandezas passa portanto pela medição das tensões geradas pelos respetivos sensores e é aí que o ADC intervém.

PWM

Embora a maioria dos microcontroladores possuam ADCs o mesmo já não se pode dizer a respeito dos DACs. Nesses casos é contudo possível gerar sinais (pseudo)analógicos através da funcionalidade PWM (Pulse Width Modulation).

A ideia é gerar um sinal digital periódico cujo valor médio de tensão possa ser alterado variando a duração do tempo em que ele está ao nível lógico 1 em relação ao período do sinal (a este valor percentual chamamos duty-cycle).

Assim, um sinal com duty-cycle igual a 0% gera um sinal digital permanentemente ao nível lógico 0 pelo que o seu valor médio de tensão é 0V.

Por seu lado, um sinal com duty-cycle igual a 100% gera um sinal digital permanentemente ao nível lógico 1 pelo que o seu valor médio de tensão é 3.3V.

Qualquer outro valor percentual, gerará na saída um sinal digital que oscila entre valores lógicos mas cujo valor médio de tensão é igual a 3,3V * duty-cycle.

Note-se que o sinal continua a ser digital mas quando aplicado a um filtro passa-baixo (que elimina as componentes de alta frequência, apenas deixando passar as de baixa frequência de que a componente média é um exemplo) parece comportar-se como se de um sinal analógico se tratasse.

Muitas vez este filtro passa-baixo pode nem existir em termos elétricos já que os sistemas biológicos ou mecânicos tratam de o implementar. Vejamos dois exemplos:

Se um sinal PWM for aplicado a um LED, desde que a frequência seja suficientemente elevada (100Hz bastariam) os nossos olhos em lugar de ver o LED a piscar têm sim a sensação de que a intensidade da luz varia em função do duty-cycle.

Se um sinal PWM for aplicado a um motor DC, ele em vez de ficar parado quando o sinal é 0 e a rodar à velocidade nominal quando o sinal é 1, em vez disso (devido à sua inércia) irá rodar a uma velocidade proporcional ao duty-cycle.

O primeiro dos exemplos será agora implementado em MicroPython (usando as funcionalidades PWM do MicroPython para o ESP32), onde o LED vermelho (ligado ao pino 21) é controlado por um sinal PWM com 3 duty-cycles diferentes (10%, 50% e 90%). Primeiro é usada uma frequência de 1Hz sendo perfeitamente possível* ver a oscilação, depois a frequência é aumentada para 100Hz (e depois para 1000Hz) que já estando para além do que a nossa visão permite distinguir, dá por isso a sensação* de que o LED está a ser controlado por um sinal analógico fazendo-se assim o controlo do seu brilho.

* Devido a um bug na versão 1.18 do firmware do MicroPython, a geração de sinais PWM com frequências abaixo de 611Hz não é feita corretamente, pelo que o efeito descrito no parágrafo anterior só é de facto observado quando é usada a frequência de 1000Hz. Este bug já foi entretanto corrigido nas versões “Nightly builds” entretanto disponibilizadas.

from machine import Pin, PWM

pwm = PWM(Pin(21))

freqs = [1, 100, 1000]
dutys = [10, 50, 90]

for freq in freqs:
    pwm.freq(freq)
    for duty in dutys:
        print("freq = {0}Hz, duty-cycle = {1}%".format(freq, duty))
        pwm.duty(int(duty*1023/100))
        input("Press Enter to continue...")

pwm.deinit()

De notar que o valor a fornecer ao método duty não é o valor percentual mas sim um número inteiro de 10 bits (entre 0 e 1023 portanto).

Note-se ainda que uma vez selecionada a frequência (método freq) e duty-cycle (método duty), o sinal é gerado pelo ESP32 de forma automática sem que o programa tenha necessidade de mudar os níveis lógicos do sinal. Isso irá manter-se até que a funcionalidade seja “desligada” através do método deinit.

Servomotores

A funcionalidade PWM pode também ser usada para gerar sinais periódicos com determinadas características, como por exemplo os usados no controlo de servomotores.

Tal como a figura seguinte mostra, neste tipo de motores, o sinal de controlo tem um período de 20ms, estando a largura do impulso relacionada com o ângulo que o seu veio realiza (sensivelmente de 0º a 180º). No caso concreto do SG90 um impulso de 0,6ms coloca o veio do motor nos 0º e um impulso de 2,4ms faz rodar o motor para os 180º:

Um programa que permitiria controlar a posição do SG90 em função da posição dum potenciómetro seria por exemplo o seguinte:

from machine import Pin, ADC, PWM

adc = ADC(Pin(35))
adc.atten(ADC.ATTN_11DB)
adc.width(ADC.WIDTH_12BIT)

pwm = PWM(Pin(5))
pwm.freq(50)
pwm.duty(int(1.5/20*1024))
while 1:
    v = adc.read()
    a = (v-600)/(3200-600)*180
    t = (2.4-a/180*(2.4-0.6))
    pwm.duty(int(t/20*1024))

pwm.deinit()

O sinal PWM de controlo é gerado no pino 5 em função da tensão presente no pino 35 e que é retirada do ponto médio dum divisor de tensão implementado pelo potenciómetro.

Polling vs Interrupts

Neste exemplo, iremos medir a frequência dum sinal digital aplicado numa entrada GPIO configurada no pino 5. Esse sinal será gerado por PWM com a frequência desejada no pino 21. Pinos 21 e 5 terão pois que ser ligados entre si!

Uma forma de proceder seria fazer polling, i.e. estar constantemente a ler o valor lógico desse sinal, contando durante um segundo o número de vezes que o sinal apresenta uma transição ascendente:

from time import ticks_ms
from machine import Pin, PWM

pwm = PWM(Pin(21))
pwm.freq(1000) # frequency to measure
pwm.duty(512) # 50%

signal = Pin(5, Pin.IN)

count = 0

last = 0
lastState = 0

while 1:
    now = ticks_ms()
    if now - last > 1000:
        if last != 0:
            print(count)
        last = now
        count = 0

    state = signal.value()
    if lastState == 0 and state == 1:
        count += 1
    lastState = state

Valores de frequência até 5,6kHz são medidos sem grandes erros, contudo a frequências mais elevadas (por exemplo 10kHz) o programa já não é suficientemente rápido para detetar todas as transições.

Uma alternativa ao polling seria tirar partido da funcionalidade interrupts onde a execução do programa será interrompida para ser executada uma função (habitualmente designada de irq: interrupt request) sempre que no sinal surgir um evento (a transição ascendente IRQ_RISING, ou descendente IRQ_FALLING):

from time import ticks_ms
from machine import Pin, PWM

pwm = PWM(Pin(21))
pwm.freq(10000) # frequency to measure
pwm.duty(512) # 50%

count = 0

last = 0

def increment(p):
    global count
    if p == signal:
        count += 1

signal = Pin(5, Pin.IN)
signal.irq(trigger=Pin.IRQ_RISING, handler=increment)

while 1:
    now = ticks_ms()
    if now - last > 1000:
        if last != 0:
            print(count)
        last = now
        count = 0

Neste caso, a frequência de 10kHz já volta a ser corretamente medida embora a partir dos 30kHz volte a ocorrer erros, agora motivados pela rotina irq ser interrompida por ela própria.

Embora se possa pensar que as diferenças nos limites de frequência são insuficientes para nos levar ao uso de interrupts, note-se que enquanto o limite fazendo polling é fortemente condicionado pelo código que adicionamos dentro do ciclo while, usando interrupts tal dependência não se verifica! Para comprovar esta situação introduza a seguinte linha de código no início do ciclo while em cada um dos programas anteriores:

while 1:
  sleep_ms(10)
  ...

Não se esqueça de que a função sleep_ms tem que ser incluída no import inicial:

from time import ticks_ms, sleep_ms

Enquanto o primeiro programa mesmo a 100Hz deixa de funcionar, o segundo (usando interrupts) continua a dar o valor esperado da frequência!

Timers

O uso de interrupts não se limita aos eventos associados a sinais ligados aos GPIOs. Com efeito, também podemos tê-los associados a temporizadores (timers).

O seguinte código faz a contagem do tempo entre cada impressão da frequência recorrendo a um timer configurado para que, de forma periódica (a cada 1000ms), interrompa a execução do programa para invocar uma função de callback (neste caso chamada print_cnt):

from machine import Pin, PWM, Timer

pwm = PWM(Pin(21))
pwm.freq(10000) # frequency to measure
pwm.duty(512) # 50%

count = 0

def print_cnt(t):
    global count
    print(count)
    count = 0

tim = Timer(-1)
tim.init(period=1000, mode=Timer.PERIODIC, callback=print_cnt)

def increment(p):
    global count
    if p == signal:
        count = count + 1

signal = Pin(5, Pin.IN)
signal.irq(trigger=Pin.IRQ_RISING, handler=increment)

def loop():
    while 1:
        pass

try:
    loop()
except KeyboardInterrupt:
    print('Got Ctrl-C')
finally:
    tim.deinit()

Alguns comentários adicionais sobre este programa:

  • Caso se pretendesse que a temporização em vez de periódica fosse realizada uma só vez, em lugar de PERIODIC deveríamos usar ONE_SHOT.
  • Para termos a garantia de que após o programa ser terminado com o Ctrl+C o timer é desligado (deinit) será necessário usar a construção try-except-finally indicada. De outra forma, quando se interromper o programa com Ctrl-C e regressar ao REPL, a rotina print_cnt continuaria a ser chamada!
  • Neste programa, aparentemente nada ocorre dentro do ciclo while!?! Com efeito todas as tarefas do programa (geração do sinal periódico, contagem das transições ascendentes, e impressão da frequência) são feitas de forma automática pelo ESP32. No primeiro caso pela geração do sinal PWM, nas duas últimas por interrupts. Isto não só liberta o ESP32 para executar outras tarefas como torna neste exemplo a medição da frequência, ainda mais precisa!

Embora o uso de interrupts seja dispensável no projeto semáforo, a sua utilização quer na deteção dos botões, quer para a realização das temporizações, pode facilitar o seu desenvolvimento!

Low Power

Em aplicações onde o ESP32 é alimentado com pilhas, o consumo energético é crucial para permitir uma maior autonomia do dispositivo.

O consumo do ESP32 em funcionamento normal é de cerca de 45mA (caso se ative o WiFi o consumo médio sobe para cerca de 120mA com picos que podem atingir 300mA) enquanto que se colocado em modo DeepSleep o consumo desce para a ordem dos 100uA (ver página 22 da datasheet do ESP32)!

Considerando por exemplo uma bateria com uma capacidade de 2000mAh, um consumo contínuo de 120mA permitiria ter o ESP32 ligado durante cerca de apenas 17 horas. Se pelo contrário, o ESP32 for colocado em modo DeepSleep durante 10 minutos sendo depois acordado por 5 segundos, o consumo médio desce para cerca de 1mA(1) que permitiria ter o sistema alimentado pela mesma bateria por um período de 2000 horas, ou seja quase 3 meses!!!

(1) 1mA = (10*60*100uA + 5*120mA) / (10*60 + 5)

No modo DeepSleep grande parte dos componentes internos do ESP32 são desligados, permanecendo apenas alguns ativos como o RTC (Real Timer Clock).

Uma vez em DeepSleep, existem depois várias formas de “acordar” o ESP32. No exemplo seguinte (que usa as funcionalidades DeepSleep do MicroPython para o ESP32) iremos usar duas dessas formas:

  • a ativação dum dos seus pinos GPIO (pino 14)
  • o decorrer duma determinada temporização (neste exemplo após 10 segundos)

Após “acordar” o ESP32 iniciará de novo a execução do programa do seu início (não do ponto onde foi iniciado o “DeepSleep”)!

from time import sleep
import machine
import esp32
from machine import Pin

led_red = Pin(21, Pin.OUT)
led_red.value(True)

causes = {
    1: "PWRON_RESET",
    2: "HARD_RESET",
    3: "WDT_RESET",
    4: "DEEPSLEEP_RESET",
    5: "SOFT_RESET"
}
cause = machine.reset_cause()
print(cause, causes[cause])

reasons = {
    0: "NO REASON",
    2: "EXT0_WAKE/PIN_WAKE",
    3: "EXT1_WAKE",
    4: "TIMER_WAKE",
    5: "TOUCHPAD_WAKE",
    6: "ULP_WAKE"
}
reason = machine.wake_reason()
print(reason, reasons[reason])

wakepin = Pin(14, Pin.IN)

esp32.wake_on_ext0(pin=wakepin, level=esp32.WAKEUP_ALL_LOW)

print('Im awake. Going to sleep in 5 seconds')
sleep(5)
print('Going to sleep now')
machine.deepsleep(10000)

De notar que quando o ESP32 entra em DeepSleep os GPIOs são desligados. Isto pode ser constatado neste exemplo pelo facto do LED que é ligado durante o funcionamento normal, ser apagado assim que se entra em DeepSleep.

No caso particular da placa ESP32-PICO-KIT, mesmo em DeepSleep são alimentados outros circuitos nela presentes para além do ESP32 (por exemplo o “USB bridge” e o “LDO regulator”). Como a seguinte figura mostra o valor de corrente oscila entre 8mA e 54mA donde resulta o valor médio de 24,56mA indicado:

Aula laboratorial

Projeto pot2pwm

Faça uma cópia deste projeto Wokwi, e edite o seu ficheiro main.py de forma a que o LED varie a sua intensidade (por PWM) em função da posição do potenciómetro.

Escolha uma frequência para a geração do sinal PWM igual a 1000Hz.

Observe no osciloscópio o sinal PWM gerado no pino 21 para diferentes posições do potenciómetro.

Projeto sinewave

Pretende-se gerar no pino 25 do ESP32 ondas sinusoidais segundo a seguinte fórmula:

v = A . sen (2.pi.F.t) + M

Os valores da amplitude (A) em Volt, frequência (F) em Hz, e valor médio (M) em Volt, poderão ser constantemente introduzidos recorrendo à função input() do Python, precedendo o valor pretendido pela respetiva letra. Por exemplo A0.5 torna a amplitude da sinusoide igual a meio Volt.

Sugestão: Defina a frequência de amostragem (100Hz) através dum Timer!

Observe no osciloscópio a forma de onda gerada no pino 25 com diferentes valores de A, F e M.