ESP32 with Dual-Channel DAC Output

ตัวอย่างการเขียนโค้ดไมโครไพธอน สำหรับ ESP32 เพื่อสร้างสัญญาณเอาต์พุต โดยใช้วงจร DAC (Digital-to-Analog Converter) ที่อยู่ภายใน ESP32 จำนวน 2 ช่องสัญญาณ

การใช้งาน DAC ของ ESP32

ถ้าต้องการใช้งานวงจร DAC ภายในชิป ESP32 ก็มี 2 ช่องสัญญาณสำหรับขา I/O ให้เลือกใช้ได้ และในส่วนการเขียนโค้ดไมโครไพธอน ก็มีคำสั่งในไลบรารี machine.DAC ไว้ใช้งานร่วมกับ machine.Pin

เมื่อแปลงข้อมูลเป็นสัญญาณไฟฟ้า จะได้แรงดันไฟฟ้าในช่วง 0V และไม่เกิน +3.3V (VCC) แต่ความละเอียดของข้อมูลเอาต์พุตสำหรับ DAC ของ ESP32 นั้น มีขนาดเพียง 8 บิต ดังนั้นจึงเขียนค่าเป็นจำนวนเต็มได้ในช่วง 0..255 เท่านั้น

การสร้างสัญญาณรูปไซน์ (Sine Wave Generation)

ถ้าเราต้องการจะสร้างเอาต์พุตให้มีลักษณะเป็นรูปคลื่นสัญญาณไซน์โดยใช้ ESP32 แต่มีแรงดันไฟฟ้าอยู่ระหว่าง 0V แต่ไม่เกิน +3.3V จะเขียนโค้ดอย่างไร ?

ถ้าสร้างสัญญาณรูปคลื่นไซน์ได้ (Sinusoidal Waveform) และมีจำนวนสองฟังก์ชันที่ขึ้นกับตัวแปรเวลา t แล้วนำมากำหนดพิกัด (x(t),y(t)) ในการวาดกราฟแบบพาราเมตริก (Parametric Plot) ก็จะเป็นการวาดเส้นโค้งให้ได้รูปที่เรียกว่า “Lissajous Figures” (“รูปลิสซาจูส์”)

กำหนดให้ฟังก์ชันทั้งสองมีความต่างเฟสกัน (เช่น ให้มีความต่างเฟสกัน 90 องศา) อาจมีความถี่เท่ากันหรือต่างกัน (แต่เป็นอัตราส่วนที่เป็นจำนวนตรรกยะ)

ลองมาดูตัวอย่างการสร้างฟังก์ชันสำหรับ x(t) และ y(t) ให้เป็นรูปแบบฟังก์ชันไซน์ ในกรณีนี้ ค่าของ x(t) และ y(t) จะอยู่ระหว่าง -A และ A ถ้า A > 0 ซึ่งเป็นค่าแอมพลิจูดของฟังก์ชันทั้งสอง

x(t) = A\, sin(\omega_0 t),\;\; y(t) = A\, sin(\omega_0 t + \phi) \\ \phi = \mbox{Phase Difference}

ถ้าให้ผลต่างเฟส (Phase Difference) เท่ากับ 90 องศา ก็จะเขียนใหม่ได้เป็น

แต่ถ้าจะนำไปวาดรูปกราฟด้วยคอมพิวเตอร์หรือประมวลผลเชิงตัวเลข ตัวแปร t จะถูกแทนที่ด้วยตัวแปร i ที่เป็นเลขจำนวนเต็ม (เป็น discrete-time steps) เช่น อยู่ในช่วง 0 ถึง (N-1) สำหรับลำดับของข้อมูลหรือจุดพิกัดที่มีจำนวนเท่ากับ N

ดังนั้นเราสามารถสร้างฟังก์ชันสำหรับพิกัด (x,y) ที่ขึ้นอยู่กับตัวแปร i ที่เป็นเลขจำนวนเต็ม (เป็น index แทนการใช้ตัวแปร t) ตามรูปสมการดังนี้

ถ้าให้ N เป็นเลขจำนวนเต็มบวก เราสามารถสร้างอาร์เรย์เพื่อเก็บค่าที่คำนวณไว้ล่วงหน้า ใช้เป็นตารางค่าคงที่ (Lookup Table) หรือบางทีก็เรียกว่า Waveform Table ดังนั้นเวลาจะสร้างสัญญาณเอาต์พุต ก็อ่านค่าตัวเลขดังกล่าวไปตามลำดับจนครบแล้ววนซ้ำ

ข้อสังเกต: การเลือกค่าสำหรับ N จะมีผลต่อขนาดของตารางและการใช้หน่วยความจำของไมโครคอนโทรลเลอร์ และเมื่อคำนวณค่าสำหรับ (x,y) จะต้องกำหนดให้เป็นค่าเลขจำนวนเต็ม และอยู่ในช่วง 0..255 (มีค่ากลางเท่ากับ 127) และเมื่อนำไปบวกหรือลบกับ A (แอมพลิจูด) จะต้องอยู่ในช่วงดังกล่าว เมื่อจะนำไปใช้กับ DAC ของ ESP32

อย่างไรก็ตาม เอาต์พุตที่ได้และเมื่อแปลงเป็นแรงดันไฟฟ้าแล้ว มีค่าไม่ต่อเนื่อง (Discrete Values) ในเชิงแอมพลิจูด ( ถ้าไม่มีวงจรกรองสัญญาณที่เรียกว่า Smoothing Filter )

ตัวอย่างการเขียนโค้ด

ในโค้ดตัวอย่างนี้ เราจะใช้ตัวแปร index เริ่มนับขึ้นจาก 0 ไปจนถึง (N-1) แล้ววนซ้ำเริ่มใหม่ ค่าของตัวแปรนี้จะถูกใช้ในการอ่านค่าจากอาร์เรย์ sin_table และ cos_table ที่ได้มีการคำนวณค่าเก็บไว้ก่อนแล้วหลังจากได้ทำคำสั่งในฟังก์ชัน create_wave_tables()

ค่าที่อ่านได้จากตารางตามตำแหน่งที่อ้างอิงด้วยตัวแปร index ในแต่ละครั้ง จะถูกนำไปใช้เป็นค่าเอาต์พุต DAC จำนวน 2 ช่องสัญญาณตามลำดับ การทำงานในลูปแต่ละรอบ จะไม่มีการหน่วงเวลา และความถี่ของสัญญาณเอาต์พุตที่ได้ จะขึ้นอยู่กับความเร็วในการประมวลผลของ ESP32 ที่เขียนโค้ดด้วยไมโครไพธอน

ในโค้ดตัวอย่างนี้ ได้เลือกใช้ค่า N=200, A=100, P=2 และ Q=3

import utime as time
from machine import Pin, DAC
import math

DAC_PINS = [25,26] # ESP32 DAC pins

def dac_init():
    dac_units = []
    for pin in DAC_PINS:
       dac = DAC( Pin(pin, Pin.OUT) )
       dac.write(0)
       dac_units.append( dac )
    return dac_units

# global variables (constants)
P = 2
Q = 3
N = 200
A = 100
Omega = 2*math.pi/N

# global variables for wave tables
sin_table = []
cos_table = []

def create_wave_tables():
    global sin_table, cos_table
    for i in range(N):
        arg = Omega*i
        x = A*math.sin( P*arg ) + 127
        y = A*math.cos( Q*arg ) + 127
        sin_table.append( int(x) )
        cos_table.append( int(y) )

# global variable (main-loop condition)
running = True

def btn_cb(p):
    global running
    running = False
 
BUTTON_C_PIN = const(37)
btnC = Pin( BUTTON_C_PIN, Pin.IN, Pin.PULL_UP )
btnC.irq( trigger=Pin.IRQ_FALLING, handler=btn_cb )

# global variable for indexing the wave tables
index = 0 # index range: 0..(N-1)

try:
    create_wave_tables()
    dac_outputs = dac_init()
    while running:
        x = sin_table[ index ]
        y = cos_table[ index ]
        index = (index+1) % N
        dac_outputs[0].write( x )
        dac_outputs[1].write( y )
except KeyboardInterrupt:
    pass
for dac in dac_outputs:
    dac.write(0)
print('Done')

การหยุดการทำงานของโค้ด ทำได้โดยการกดปุ่มที่ต่อกับขา GPIO-37 (ทำงานแบบ Active-Low) และถ้าใช้อุปกรณ์ M5Stack-Core ก็จะตรงกับปุ่ม Button C (BtnC)

ถัดไปลองเปลี่ยนมาใช้ Hardware Timer ของ ESP32 เป็นตัวกำหนดจังหวะการเขียนค่าเอาต์พุตให้ DAC จะเขียนโค้ดอย่างไร มาดูตัวอย่างกัน

ในโค้ดตัวอย่างนี้ เราใช้คำสั่งที่เกี่ยวข้องกับ machine.Timer เพื่อเปิดใช้งาน Hardware Timer (เลือกใช้หมายเลข 4 )

การทำงานของไทม์เมอร์ (Timer) เป็นแบบ periodic คือ จะทำให้เกิดอินเทอร์รัพท์จากไทม์เมอร์ซ้ำอีก โดยระบุคาบ (Period) เป็นตัวเลขจำนวนเต็มบวก (หน่วยเป็น มิลลิวินาที)

เมื่อเกิดอินเทอร์รัพท์ในแต่ละครั้ง และฟังก์ชัน Callback (Interrupt Handler) ที่เกี่ยวข้องจะคอยทำหน้าที่อ่านค่าจากตารางค่าคงที่ตามลำดับของ index แล้วเขียนค่าเอาต์พุตสำหรับ DAC ทั้งสองช่อง

ถ้ากำหนดให้ N=200 และคาบเวลาเท่ากับ 1 msec จะได้สัญญาณเอาต์พุต (ไซน์) ที่มีคาบเท่ากับ 200 msec หรือมีความถี่เท่ากับ 5 Hz (กำหนดให้ P=Q=1)

import utime as time
from machine import Pin, DAC, Timer
import math

DAC_PINS = [25,26] # ESP32 DAC pins

def dac_init():    
    dac_units = []
    for pin in DAC_PINS:
       dac = DAC( Pin(pin, Pin.OUT) )
       dac.write(0)
       dac_units.append( dac )
    return dac_units

# N=200, Timer period=1ms => output freq. 5Hz (for P=Q=1)
# global variables (constants)
P = 2
Q = 3
N = 200
A = 100
Omega = 2*math.pi/N

# global variables for storing values in wave tables
sin_table = []
cos_table = []

# global variable for indexing the wave tables
index = 0 # range: 0..(N-1)

def create_wave_tables():
    global sin_table, cos_table
    for i in range(N):
        arg = Omega*i
        x = A*math.sin( P*arg ) + 127
        y = A*math.cos( Q*arg ) + 127
        sin_table.append( int(x) )
        cos_table.append( int(y) )
 
# callback for hardware timer
def timer_cb(t):
    global dac_outputs, index
    index = (index+1) % N
    x = sin_table[ index ]
    y = cos_table[ index ]
    dac_outputs[0].write( x )
    dac_outputs[1].write( y )

# use Hardware Timer 4 in periodic mode (period=1ms)
timer = Timer(4)
timer.init(period=1, mode=Timer.PERIODIC, callback=timer_cb)

# global variable
running = True

def btn_cb(p):
    global running
    running = False

# constant
BUTTON_C_PIN = const(37)

# use button on GPIO-37
btnC = Pin( BUTTON_C_PIN, Pin.IN, Pin.PULL_UP )
btnC.irq( trigger=Pin.IRQ_FALLING, handler=btn_cb )

try:
    dac_outputs = dac_init()
    create_wave_tables()
    while running:
        time.sleep_ms(10)
except KeyboardInterrupt:
    pass

timer.deinit()
for dac in dac_outputs:
    dac.write(0)
print('Done')

ถ้าเราลองวัดสัญญาณที่ขาเอาต์พุตของ DAC ทั้งสองช่อง ที่ขา GPIO-25 และ GPIO-26 โดยใช้เครื่องออสซิลโลสโคป และเลือกโหมดการแสดงผลเป็นแบบ X-Y แทนการใช้โหมด Y-T และเลือก Coupling Mode เป็นแบบ AC จะได้รูปตามตัวอย่างดังนี้ (ถ้ามีโอกาสได้ทดลองกับอุปกรณ์จริง ให้ทดลองเปลี่ยนค่า P และ Q)

เราสามารถนำไปเปรียบเทียบรูปคลื่นสัญญาณที่สร้างจากเครื่องกำเนิดสัญญาณ (Function Generator) แบบสองช่องเอาต์พุต

โค้ดวาดรูปกราฟสำหรับ ESP32-M5Stack

จากการกำหนดรูปแบบของฟังก์ชันสำหรับพิกัด (x,y) เราก็มาดูตัวอย่างการเขียนโค้ดไมโครไพธอน เพื่อลองวาดรูปกราฟ และแสดงผลบนจอ LCD ขนาด 320x240 ของอุปกรณ์ M5Stack-Core (ESP32-based) โดยมีการสุ่มค่า P และ Q ให้เป็นจำนวนเต็มในช่วง 1..10 ดังนั้นรูปกราฟหรือเส้นโค้ง (Curve) ที่ได้ จะแตกต่างกันไปตามอัตราส่วน P:Q

ถ้าเราใช้ API หรือไลบรารีของ M5Stack สำหรับภาษาไมโครไพธอน ก็มีคำสั่ง lcd.drawPixel() เพื่อวาดจุดหนึ่งพิกเซลบนสกรีน (Screen) โดยระบุตำแหน่งหรือค่า (x,y)

ถ้าต้องการเชื่อมต่อจุดเหล่านั้นที่อยู่ถัดไปตามลำดับด้วยเส้นตรง ก็สามารถใช้คำสั่ง lcd.drawLine() พร้อมกำหนดสีของเส้นตรงนั้นได้ด้วย

ในโค้ดตัวอย่างนี้ ได้กำหนดให้ N=256 และ A=100

## Parametric plot: Lissajous figure 
from m5stack import *
import utime as time
import math
from urandom import *

# define the screen size (width and height in pixels)
scr_w, scr_h = (320,240)

def draw_curve(A,N,P,Q):
    lcd.clear()
    last_point = None
    line_color = lcd.YELLOW
    omega = 2*math.pi/N
    phase = math.pi/2
    for i in range(N+1):
        x = scr_w//2 + int( A*math.sin( P*omega*i + phase ) )
        y = scr_h//2 - int( A*math.sin( Q*omega*i ) ) 
        if last_point:
            lx, ly = last_point
            lcd.drawLine( lx, ly, x, y, line_color )
        else:
            lcd.drawPixel( x, y, line_color )
        last_point = (x,y)
    text = 'P={}, Q={}'.format(P,Q)
    lcd.print(text, lcd.CENTER, 222, lcd.GREEN)

# global variable (for the main loop condition)
running = True

def btnC_callback():
    global running
    running = False
    
btnC.wasPressed( btnC_callback )

try:
    while running:
        P = randint(1,10)
        Q = randint(1,10)
        draw_curve( 100, 256, P, Q )
        time.sleep(3.0)
except KeyboardInterrupt:
    pass
lcd.clear()
print('Done')

โค้ดนี้จะทำงานต่อเนื่องไปเรื่อย ๆ ถ้าต้องการหยุดการทำงาน ให้กดปุ่มแล้วปล่อยที่ Button C (BtnC) ของ M5 Stack

กล่าวสรุป

เราได้เห็นตัวอย่างการเขียนโค้ดไมโครไพธอนสำหรับ ESP32 เพื่อสร้างสัญญาณเอาต์พุตเป็นรูปคลื่นไซน์ โดยใช้ DAC จำนวน 2 ช่อง เพื่อวาดรูปเส้นโค้ง Lissajous แล้ววัดสัญญาณด้วยเครื่องออสซิลโลสโคป เพื่อดูคลื่นสัญญาณที่ได้ในโหมด XY และได้สาธิตการเขียนโค้ดเพื่อวาดรูปเส้นโค้ง Lissajous และแสดงผลบนจอ LCD ของอุปกรณ์ M5Stack Core

เผยแพร่ภายใต้ลิขสิทธิ์ Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)

Last updated