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 apenas iguais a 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/255 * 3.3V.

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.

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), 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).

A gama de tensões a converter pode também ser definida através da escolha do valor máximo que para além de 3,60V pode também ser 2,00V, 1,34V e 1,00V, escolhendo respetivamente a atenuação ATTN_11DB, ATTN_6DB, ATTN_2_5DB e ATTN_0DB.

No seguinte programa 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.

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, 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 que já está para além do que a nossa visão permite distinguir, dando 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.

from machine import Pin, PWM

pwm = PWM(Pin(21))

freqs = [1, 100]
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 utime import ticks_ms
from machine import Pin, PWM

pwm = PWM(Pin(21))
pwm.freq(100) # 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é 7kHz 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 utime 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 19kHz 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 utime 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
from machine import 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 longevidade 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 5uA!

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* (10*60*0,005 + 5*120) que permitiria ter o sistema alimentado pela mesma bateria por um período de 2000 horas, ou seja quase 3 meses!!!

* 1mA = (10*60*5uA + 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 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)
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.