[TH] LEDs on/off via PyQt5 and serial communication.

ในบทความนี้ใช้ความรู้จากบทความการอ่านรายชื่อพอร์ตอนุกรมที่ถูกเชื่อมต่อมาปรับปรุงให้เป็นการส่งข้อมูลส่งไปให้บอร์ด Arduino Uno ที่มีหลอดแอลอีดี (LED) เชื่อมต่ออยู่ที่ขา 2,3,4,5,6,7,8 และ 9 ทำให้ผู้ใช้งานสามารถสั่งเปิดหรือปิดหลอดดังกล่าวด้วยการสั่งงานผ่าน GUI (Graphics User Interface) ของ PyQt5 ดังภาพที่ 1 และส่งข้อมูลไปให้บอร์ด Uno ทางพอร์ตสื่อสารอนุกรมด้วย pySerial โดยในตัวอย่างครั้งนี้ การทำงานรองรับทั้งระบบปฏิบัติการ Windows, macOS และ Linux ซึ่งจะทำให้เห็นว่า PyQt5 และ pySerial สามารถรองรการทำงานกับทั้ง 3 ระบบได้

บทความนี้ประกอบด้วย 3 ตอน คือ

  1. List the serial ports connected to the RPi with pySerial.
  2. List the serial ports connected to the RPi with pySerial and PyQt5.
  3. LEDs on/off via PyQt5 and serial communication.

ปรับปรุงโค้ด

จากโค้ดในครั้งก่อนได้อ่านรายการพอร์ตทั้งหมดของระบบปฏิบัติการแบบ Linux โดยพิจารณาชื่ออุปกรณ์ที่ขึ้นต้นด้วย /dev/tty ในครั้งนี้ได้ปรับเปลี่ยนให้รองรับกับ 3 ระบบปฏิบัติการ โดยอ่านรายชื่อระบบปฏิบัติการจากคุณสมบัติ platform ของไลบรารี sys

  • ถ้าขึ้นต้นด้วย ‘win’ ให้ใช้ค่าพอร์ตเป็น ‘COM1’, ‘COM2’, .., ‘COM127’
  • ถ้าขึ้นต้นด้วย ‘linux’ ให้ใช้ค่าพอร์ตเป็นรายการที่ขึ้นต้นด้วย /dev/tty
  • ถ้าคำขึ้นต้นเป็น ‘darwin’ ซึ่งหมายถึงระบบปฏบัติการ macOS ให้ใช้ค่าพอร์ตขึ้นต้นด้วย /dev/cu.
  • กรณีอื่น ๆ ให้รายงานว่าไม่รู้จักระบบปฏิบัติการ

โค้ดโปรแกรมตัวอย่างเป็นดังนี้

        if sys.platform.startswith('win'):
            ports = ['COM%s' % (i + 1) for i in range(128)]
        elif sys.platform.startswith('linux'):
            ports = glob.glob('/dev/tty[AU]*')
        elif sys.platform.startswith('darwin'):
            ports = glob.glob('/dev/cu.*')
        else:
            raise EnvironmentError('ไม่รองรับการทำงานของระบบปฏิบัติการนี้!!!')

glob

คลาส glob ใช้สำหรับค้นหาชื่อไฟล์บทระบบปฏิบัติการยูนิกซ์ (Unix) ตามรูปแบบที่กำหนด โดยการใช้งานให้นำเข้าไลบรารี glob และเรียกใช้ผ่านทางวัตถุ glob

ตัวอย่างการค้นหาไฟล์ที่ชื่อ /dev/abc และ /dev/ttyUSB0 สามารถเขียนโค้ดได้ดังนี้ ซึ่งได้ตัวอย่างผลลัพธ์ดังภาพที่ 2

import glob
result = glob.glob("/dev/abc")
print(result)
result = glob.glob("/dev/ttyUSB")
print(result)
ภาพที่ 2 ตัวอย่างการค้นหาไฟล์ /dev/abc และ /dev/ttyUSB0

นอกจากระบุชื่อไฟล์ตรง ๆ แล้ว ผู้เขียนโปรแกรมสามารถใส่ wildcard ได้แก่ * และ ? เข้าไปได้ ซึ่ง

  • เครื่องหมาย * แทนตัวอักษรหรือตัวเลขใด ๆ จำนวนเท่าใดก็ได้
  • เครื่องหมาย ? แทนตัวอักษรหรือตัวเลขใด ๆ จำนวน 1 ตัว

ตัวอย่างในภาพที่ 3 เป็นการสั่งค้นหา /dev/tty* กับ /dev/tty? ซึ่งในแบบแรกจะรายงานทุกไฟล์ที่ขึ้นต้นด้วย /dev/tty และในแบบที่ 2 จะค้นหาเฉพาะ /dev/tty ที่มีตัวเลขหรือตัวอักษรตามมา 1 หลัก

ภาพที่ 3 ตัวอย่างการใช้ * และ ? กับ glob

และแบบสุดท้ายคือระบุคำค้นเป็น /dev/tty[A]* และ /dev/tty[AU]* จะได้ผลลัพธ์ดังตัวอย่างในภาพที่ 4 ซึ่งคำสั่งแรกหมายถึงค้นหา /dev/tty ที่มี A ตามมา และที่เหลือเป็นอะไรก็ได้ ส่วนแบบที่ 2 เป็นการหา /dev/tty ที่ตามมาด้วย A หรือ U และที่เหลือเป็นอะไรก็ได้ และนอกจากนี้สามารถกำหนดเป็นช่วงได้ เช่น /dev/tty[A-U]* สำหรับหาที่ขึ้นต้นด้วย /dev/tty ตามด้วยตัวอักษร A ถึง U พิมพ์ใหญ่ และตามด้วยอะไรก็ได้ และ /dev/tty[A-Ua-u]* สำหรับหาที่ขึ้นต้นด้วย /dev/tty ตามด้วย A ถึง u หรือ a ถึง u และตามด้วยตัวอักษรหรือตัวเลขใดก็ได้

ภาพที่ 4 ตัวอย่างการหาแบบกำหนดช่วงตัวอักษร

PyQt5

วิดเจ็ตที่นำมาใช้เพิ่มเติมจากบทความก่อนหน้านี้ที่เกี่ยวกับ PyQt5 ที่ได้เคยกล่าวถึงไปแล้ว คือ คล่า QCheckBox ซึ่งเป็นส่วนติดต่อกับผู้ใช้สำหรับให้ผู้เลือกหรือไม่เลือกรายการนั้น และรองรับการเลือกพร้อมกันหลายรายการ

QCheckBox

วิดเจ็ต QCheckBox ใช้สำหรับสร้างวิดเจ็ตนำเข้าข้อมูลจากผู้ใช้โดยให้ผู้ใช้เลือกรายการที่สร้างขึ้น โดยการสร้างวัตถุประเภท เช็คบ็อกซ์จะต้องนำเข้าคลาส QCheckBox ที่สืบทอดมาจาก QtWidgets.QAbstractButton และสืบทอดมาจาก QtWidgets.QtWidget อีกทอดหนึ่ง ซึ่งการสร้างวัตถุประเภทนี้มีรูปแบบการสร้างดังนี้ ส่วนพารามิเตอร์ที่ต้องส่งให้นั้นประกอบด้วย ข้อความที่แสดงด้านหลังปุ่มรายการเลือก และค่าตำแหน่งของหน้าต่างหลักที่ต้องการให้เลเบลนี้ไปแสดงบนหน้าต่าง โดยส่วนของข้อความสามารถกำหนดเป็นค่าใด ๆ และสามารถเปลี่ยนได้ในภายหลัง ส่วนหน้าต่างหลักในตัวอย่างโปรแกรมจะใช้เป็น self เพื่อระบุว่า เลเบลอยู่ภายใต้หน้าต่างที่ถูกสร้างขึ้น

วัตถุ = QCheckBox( ข้อความ, หน้าต่างหลัก )

การกำหนดข้อความให้กับวัตถุประเภท QCheckBox ใช้เมธอดตามรูปแบบต่อไปนี้

วัตถุ.setText( ข้อความ )

กรณีที่ต้องการให้ปรับขนาดของ QCheckBox ใหม่ให้เหมาะกับข้อความที่กำหนดให้เรียกใช้ฟังก์ชันต่อไปนี้

วัตถุ.adjustSize()

สำหรับการระบุตำแหน่งแสดงวิดเจ็ตกำหนดได้คำสั่ง move() ดังรูปแบบของการใช้งานต่อไปนี้

วัตถุ.move( x, y)

การตรวจสอบว่ารายการถูกเลือกหรือไม่ใช้คำสั่งดังนี้

สถานะการเลือก = วัตถุ.isChecked()

สำหรับการกำหนดให้รายการนั้นถูกเลือกทำได้โดยการส่งค่า True หรือ False เป็นพารามิเตอร์ของคำสั่ง setCheckState() ดังนี้

วัตถุ.setCheckState( สถานะ )

ตัวอย่างโปรแกรม

โปรแกรมประกอบด้วย 2 ส่วน คือ ส่วนของ Arduino Uno และส่วนของ GUI

โปรแกรมสำหรับ Arduino Uno

บอร์ด Arduino Uno ได้ต่อหลอดแอลอีดีไว้ที่ขา 2,3,4,5,6,7,8 และ 9 ดังภาพที่ 5 ซึ่งหลักการทำงานของโปรแกรมเป็นดังต่อไปนี้

  • เปิดการสื่อสารพอร์ตอนุกรมด้วยความเร็ว 115200bps หรือที่ตรงกันกับโปรแกรมฝั่ง GUI
  • ปรับให้ทุกขาทำให้หลอดดับ
  • ส่วนของ loop
    • ถ้ามีข้อมูลนำเข้าทางพอร์ตสื่อสารอนุกรมของ Arduino Uno
      • อ่านข้อความมาเก็บใน s
      • แปลง s เป็นตัวเลข x
      • ตรวจสอบแต่ละบิตของ x ถ้าเป็น 1 ให้ทำดังนี้
        • เปิดหลอดแอลอีดีของขา 2
        • ถ้าไม่ใช่ให้ปิด
ภาพที่ 5 หลอดแอลอีดีที่ขา 2,3,4,5,6,7,8 และ 9

โค้ดโปรแกรมคือ

#include <Arduino.h>
uint8_t leds[] = {2, 3, 4, 5, 6, 7, 8, 9};
void setup() {
  Serial.begin( 115200 );
  for (int i = 0; i < 8; i++) {
    pinMode( leds[i], OUTPUT );
    digitalWrite( leds[i], LOW );
  }
}

void loop() {
  if (Serial.available()) {
    // มีข้อมูล
    String s = Serial.readString();
    // Serial.print(s);
    uint8_t x = s.toInt();
    if (x & 0b00000001) {
      digitalWrite( leds[0], HIGH );
    } else {
      digitalWrite( leds[0], LOW );
    }
    if (x & 0b00000010) {
      digitalWrite( leds[1], HIGH );
    } else {
      digitalWrite( leds[1], LOW );
    }
    if (x & 0b00000100) {
      digitalWrite( leds[2], HIGH );
    } else {
      digitalWrite( leds[2], LOW );
    }
    if (x & 0b00001000) {
      digitalWrite( leds[3], HIGH );
    } else {
      digitalWrite( leds[3], LOW );
    }
    if (x & 0b00010000) {
      digitalWrite( leds[4], HIGH );
    } else {
      digitalWrite( leds[4], LOW );
    }
    if (x & 0b00100000) {
      digitalWrite( leds[5], HIGH );
    } else {
      digitalWrite( leds[5], LOW );
    }
    if (x & 0b01000000) {
      digitalWrite( leds[6], HIGH );
    } else {
      digitalWrite( leds[6], LOW );
    }
    if (x & 0b10000000) {
      digitalWrite( leds[7], HIGH );
    } else {
      digitalWrite( leds[7], LOW );
    }
  }
}

โปรแกรมของฝั่ง GUI

โปรแกรมของฝั่ง GUI ยังคงใช้ PyQt5 เป็นไลบรารีสำหรับทำงาน โดยเพิ่มส่วนของการรองรับระบบปฏิบัติการในการตรวจสอบชื่อของอุปกรณ์ที่เชื่อมต่อสื่อสารกับพอร์ตอนุกรมดังที่ได้กล่าวไว้ก่อนหน้านี้แล้ว และการทำงานหลักของโปรแกรมเป็นดังนี้

  • สร้างหน้าต่าง และวิดเจ็ต
  • ตรวจสอบรายชื่อพอร์ตสื่อสารอนุกรมที่เชื่อมต่อ
  • ถ้าผู้ใช้กดปุ่ม ‘ส่งข้อมูล’
    • ตรวจสอบว่ามีพอร์ตที่เชื่อมต่อ
    • แปลงข้อมูลจากรายการ check box ให้เป็นตัวเลข
    • แปลงตัวเลขเป็นข้อความ
    • แปลงข้อความเป็น bytearray
    • เปิดพอร์ตสื่อสาร
    • ส่ง bytearray
    • ปิดพอร์ตสื่อสาร

โค้ดโปรแกรมเป็นดังต่อไปนี้ โดยมีหน้าจอแสดงผลดังภาพที่ 1 และสิ่งที่ต้องตะหนักอยู่เสมอ คือ การรับ/ส่งข้อมูลนั้น ตัวข้อมูลอยู่ในรูปแบบ bytearray ดังนั้น ต้องทำการแปลงข้อมูลไปกลับด้วย ตัวแปรสตริง.decode() และ ตัวแปรสตริง.encode()

# -*- coding: UTF-8 -*-
import sys
import glob
import serial
from PyQt5.QtWidgets import QApplication, QCheckBox, QLabel, QComboBox, QWidget, QMainWindow, QPushButton
from PyQt5.QtGui import QIcon, QColor

class MyApp(QMainWindow):
    def __init__(self, title, w=640, h=480):
        super().__init__()
        self.setWindowTitle(title)
        self.setGeometry(0, 0, w, h)
        self.status = False

        self.lbl = QLabel("รายการพอร์ตอนุกรม", self)
        self.lbl.move( 100, 20 )
        self.lbl.adjustSize()

        self.lbl2 = QLabel("สถานะ", self)
        self.lbl2.move( 100, 70)
        self.lbl2.adjustSize()

        self.cbb = QComboBox(self)
        self.cbb.move( 220, 20 )

        ## serial port
        if sys.platform.startswith('win'):
            ports = ['COM%s' % (i + 1) for i in range(128)]
        elif sys.platform.startswith('linux'):
            ports = glob.glob('/dev/tty[A-Za-z]*')
        elif sys.platform.startswith('darwin'):
            ports = glob.glob('/dev/cu.*')
        else:
            raise EnvironmentError('ไม่รองรับการทำงานของระบบปฏิบัติการนี้!!!')
        for port in ports:
            try:
                s = serial.Serial(port)
                s.close()
                self.cbb.addItem(port)
            except (OSError, serial.SerialException):
                pass
        self.cbb.adjustSize()

        ### Check box
        self.chkLeds = []
        for i in range(8):
            self.chkLeds.append(QCheckBox("LED{}".format(i),self))
            self.chkLeds[i].move(100+(i*64),110)

        ### send button
        self.btnSend = QPushButton("ส่งข้อมูล", self)
        self.btnSend.setToolTip("ส่งข้อมูลไปที่พอร์ตอนุกรม")
        self.btnSend.move( 200, 68 )
        self.btnSend.clicked[bool].connect(self.on_clicked_btnSend)

        ## status bar
        if self.cbb.count() > 0:
            self.statusBar().showMessage('status: พร้อมทำงาน')
        else:
            self.statusBar().showMessage('status: ไม่พบพอร์ตอนุกรม')
        self.show()


    def on_clicked_btnSend(self):
        source = self.sender()
        #print(self.cbb.count(), self.cbb.currentText())
        if (self.cbb.count() == 0):
          return
        ### แปลงข้อมูล
        data = 0
        if (self.chkLeds[0].isChecked()):
          data += 1
        if (self.chkLeds[1].isChecked()):
          data += 2
        if (self.chkLeds[2].isChecked()):
          data += 4
        if (self.chkLeds[3].isChecked()):
          data += 8
        if (self.chkLeds[4].isChecked()):
          data += 16
        if (self.chkLeds[5].isChecked()):
          data += 32
        if (self.chkLeds[6].isChecked()):
          data += 64
        if (self.chkLeds[7].isChecked()):
          data += 128
        data = "{}\n".format(data)
        encoded_string = data.encode()
        byte_array = bytearray(encoded_string)
        print(byte_array)
        port = self.cbb.currentText()
        try:
          scon = serial.Serial(port=port,baudrate=115200)
          scon.write(byte_array)
          scon.flushOutput()
          scon.close()
        except:
          self.statusBar().showMessage("{} เปิดใช้ไม่ได้!!!".format(port))

if __name__ == '__main__':
    app = QApplication(sys.argv)
    my_app = MyApp("PyQt5 serial 3",640, 320)
    sys.exit(app.exec_())

ตัวอย่างภาพที่ 6 กำหนดให้ LED0,LED2,LED4 และ LED6 ติด ได้ผลลัพธ์ดังภาพที่ 7 และจากภาพที่ 8 กำหนดให้ LED4, LED5,LED6 และ LED7 ดังภาพที่ 9

ภาพที่ 6 เปิดหลอด LED0, LED2, LED4 และ LED6
ภาพที่ 7 ตัวอย่างผลลัพธ์จากบอร์ด Arduino Uno
ภาพที่ 8 เปิดหลอด LED4, LED5, LED6 และ LED7
ภาพที่ 9 ตัวอย่างผลลัพธ์จากบอร์ด Arduino Uno เมื่อเลือก LED4, LED5, LED6 และ LED7

สรุป

จากบทความนี้จะพบว่า เมื่อเราใช้ PyQt5 และ pySerial ทำให้เราสามารถเขียนโปรแกรมสื่อสารกับอุปกรณ์ที่เป็นบอร์ดไมโครคอนโทรลเลอร์ผ่านทางพอร์ตสื่อสารอนุกรมได้ และสามารถนำไปประยุกต์ได้มากมายขึ้น แต่ผู้เขียนโปรแกรมจะต้องออกแบบรูปแบบของการสื่อสารหรือโพรโทคอล (protocol) ให้เหมาะสมกับระบบที่พัฒนาขึ้น สุดท้ายนี้ หวังว่าบทความนี้คงมีประโยชน์บ้างไม่มากก็น้อย และขอให้สนุกกับการเขียนโปรแกรมครับ

แหล่งอ้างอิง

  1. QCheckBox

(C) 2020-2021, โดย อ.ดนัย เจษฎาฐิติกุล/อ.จารุต บุศราทิจ
ปรับปรุงเมื่อ 2021-10-28