Lチカで始めるテスト自動化(18)秋月のIoT学習HATキットの圧電ブザーでテスト終了時にpass/failに応じてメロディを流す

##1. はじめに
何か作業をしている横で自動テストを実行し、自動テストが終了しているかをときどきポーリングしながら確認するのは、作業に集中しづらいものと思います。そこで[IoT学習HATキット](https://akizukidenshi.com/catalog/g/gK-14568/)に搭載されている圧電ブザーを利用して終了時にpass/failに応じてメロディを鳴らし、終了したことが音で分かるようにします。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">秋月のIoT学習HATキットに搭載されている圧電ブザーを利用してテスト終了時にpass/failに応じてメロディを流すようにしてみた。これはpassしたとき。<br>※音量注意⚠️ <a href="https://t.co/X7DQk5Vmiz">pic.twitter.com/X7DQk5Vmiz</a></p>&mdash; ka’s (@pbjpkas) <a href="https://twitter.com/pbjpkas/status/1426209700798820358?ref_src=twsrc%5Etfw">August 13, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
※上記ツイートを別のウィンドウやタブで開くとメヌエット、トッカータとフーガ ニ短調、エリーゼのためにを試聴できます。

##2. メロディ再生プログラムの実装
IoT学習HATキットの[サンプルプログラム](https://akizukidenshi.com/download/ds/akizuki/lhat.zip)に倣ってGPIOで矩形波信号を生成する[Software Tone Library](http://wiringpi.com/reference/software-tone-library/)を利用します。Pythonで実装します。

###2.1 音程と周波数の定義
[Piano key frequencies](https://en.wikipedia.org/wiki/Piano_key_frequencies)に掲載されている音程と周波数の対応表を参考に適宜定義します。休符(REST)は0[Hz]とします。Software Tone Libraryの仕様により上限は5,000[Hz]です。

```python:例
#Note, Frequency
R   =   0 # rest
C3  = 131
CS3 = 139 # C♯3
（以下略）
```

###2.2 Software Tone Libraryの初期化
IoT学習HATキットの圧電ブザーはPIN番号16、GPIO23に接続されています。

```python:beepsound.py
import RPi.GPIO as GPIO
import wiringpi

# Akizuki IoT Learning HAT GPIO Number
BUZZER_PIN = 23 #PIN16, GPIO23

class BEEPSOUND:
    def __init__(self, tempo=120.0):
        GPIO.setwarnings(False)
        GPIO.setmode(GPIO.BCM)
        GPIO.setup(BUZZER_PIN, GPIO.OUT)
        wiringpi.wiringPiSetupGpio()
        wiringpi.softToneCreate(BUZZER_PIN)
        self.tempo = tempo
```

###2.3 テンポ設定
2.2節でテンポを120で初期設定していますが、任意の値に設定できるようにします。

```python:呼び出し側
set_tempo(120)
```

```python:呼ばれる側
from time import sleep

    def set_tempo(self, tempo):
        self.tempo = float(tempo)
```

###2.4 発音
1音ごとに「音程」と「音符の長さ」を指定します。鍵盤楽器であれば鍵盤を「押す」「離す」があり、押しっぱなしにならないよう、押す：95%、離す：5%の時間配分としました。

```python:呼び出し側
generate(C3,  4) # C3(ド)、4分音符
```

```python:呼ばれる側
    def generate(self, note, length):
        if float(length) != 0:
            duration = float( (4/float(length)) * (60.0/self.tempo) )
            wiringpi.softToneWrite(BUZZER_PIN, int(note))
            sleep(duration*0.95)
            wiringpi.softToneWrite(BUZZER_PIN, 0)
            sleep(duration*0.05)
```

###2.5 楽譜の記述
発音の呼び出し側を「1音＝1行」で記述します。

```python:例
generate(C3,  4) # C3(ド)、4分音符
generate(D3,  4) # D3(レ)、4分音符
generate(E3,  4) # E3(ミ)、4分音符
generate(R,   4) # 4分休符
```

「音程」と「音符の長さ」の2次元配列を作ってそのアドレス(ポインタ)を引数で渡すような作りにするほどでもないなぁと思い、「1音＝1行」でべた書きとしました。

###2.6 楽譜
以下の資料を参考にさせていただきました。pass、failの合計2曲あれば十分なのですが試作を兼ねて3曲あります。

- [エリーゼのために](https://ja.wikipedia.org/wiki/%E3%82%A8%E3%83%AA%E3%83%BC%E3%82%BC%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AB)
- [メヌエット (ペツォールト)](https://ja.wikipedia.org/wiki/%E3%83%A1%E3%83%8C%E3%82%A8%E3%83%83%E3%83%88_(%E3%83%9A%E3%83%84%E3%82%A9%E3%83%BC%E3%83%AB%E3%83%88))
- [Toccata and Fugue in D Minor](https://www.gmajormusictheory.org/Freebies/Level2/2ToccataFugueDm/2ToccataFugueDm.pdf)

###2.7 メロディ再生プログラムの全文
以下にメロディ再生プログラムの全文を示します。

```python:beepsound.py
# -*- coding: utf-8 -*-
# beepsound.py
# python 3.x
#
# This software includes the work that is distributed
# in the Apache License 2.0
# ka's@pbjpkas 2021
#

from time import sleep
import RPi.GPIO as GPIO
import wiringpi

# Akizuki IoT Learning HAT GPIO Number
BUZZER_PIN = 23 #PIN16, GPIO23

#Note, Frequency
R   =   0 #rest
C3  = 131
CS3 = 139
D3  = 147
DS3 = 156
E3  = 165
F3  = 175
FS3 = 185
G3  = 196
GS3 = 208
A3  = 220
AS3 = 233
B3  = 247
C4  = 262
CS4 = 277
D4  = 294
DS4 = 311
E4  = 330
F4  = 349
FS4 = 370
G4  = 392
GS4 = 415
A4  = 440
AS4 = 466
B4  = 494
C5  = 523


class BEEPSOUND:
    def __init__(self, tempo=120.0):
        GPIO.setwarnings(False)
        GPIO.setmode(GPIO.BCM)
        GPIO.setup(BUZZER_PIN, GPIO.OUT)
        wiringpi.wiringPiSetupGpio()
        wiringpi.softToneCreate(BUZZER_PIN)
        self.tempo = tempo

    def set_tempo(self, tempo):
        self.tempo = float(tempo)

    def generate(self, note, length):
        if float(length) != 0:
            duration = float( (4/float(length)) * (60.0/self.tempo) )
            wiringpi.softToneWrite(BUZZER_PIN, int(note))
            sleep(duration*0.95)
            wiringpi.softToneWrite(BUZZER_PIN, 0)
            sleep(duration*0.05)

    # Für Elise - Ludwig van Beethoven
    # https://ja.wikipedia.org/wiki/エリーゼのために
    def FurElise(self):
        self.generate(E4, 16)
        self.generate(DS4,16)
        
        self.generate(E4, 16)
        self.generate(DS4,16)
        self.generate(E4, 16)
        self.generate(B3, 16)
        self.generate(D4, 16)
        self.generate(C4, 16)
        
        self.generate(A3,  8)
        self.generate(R,  16)
        self.generate(C3, 24)
        self.generate(E3, 24)
        self.generate(A3, 24)
        
        self.generate(B3,  8)
        self.generate(R,  16)
        self.generate(E3, 24)
        self.generate(GS3,24)
        self.generate(B3, 24)
        
        self.generate(C4,  8)
        self.generate(R,  16)

    # Menuet - Christian Petzold
    # https://ja.wikipedia.org/wiki/メヌエット (ペツォールト)
    def Menuet(self):
        self.generate(D4,  4)
        self.generate(G3,  8)
        self.generate(A3,  8)
        self.generate(B3,  8)
        self.generate(C4,  8)
        self.generate(D4,  4)
        self.generate(G3,  4)
        self.generate(G3,  4)
        
        self.generate(E4,  4)
        self.generate(C4,  8)
        self.generate(D4,  8)
        self.generate(E4,  8)
        self.generate(FS4, 8)
        self.generate(G4,  4)
        self.generate(G3,  4)
        self.generate(G3,  4)

    # Toccata und Fuge in d-Moll BWV565 - Johann Sebastian Bach
    # https://www.gmajormusictheory.org/Freebies/Level2/2ToccataFugueDm/2ToccataFugueDm.pdf
    def ToccataUndFugeInDMoll(self):
        self.generate(A4, 16)
        self.generate(G4, 16)
        #self.generate(A4,  8)
        self.generate(A4,  2)
        self.generate(G4, 16)
        self.generate(F4, 16)
        self.generate(E4, 16)
        self.generate(D4, 16)
        self.generate(CS4, 4)
        self.generate(D4,  2)
        self.generate(R ,  4)


if __name__ == '__main__':
    beepsound = BEEPSOUND()
    beepsound.set_tempo(60)
    beepsound.FurElise()
    
    beepsound.set_tempo(120)
    beepsound.Menuet()
    beepsound.ToccataUndFugeInDMoll()
```

##3. テストランナーへの組み込み
テストランナーへ以下の改修を行います。

1. beepsound.pyをimportする
1. BEEPSOUND()を実行してインスタンスを生成する
1. 楽譜データのデバッグのためにbeepsoundコマンドを追加する
1. failして終了する箇所に「エリーゼのために」を追記する
1. passして終了する箇所に「メヌエット」を追記する

「Lチカで始めるテスト自動化(16)秋月のIoT学習HATキットにBME280を接続してテスト実行環境の温度・湿度・気圧を取得する」の[付録.A test-runner.pyのソース](https://qiita.com/pbjpkas/items/0a48ca71a2de0042e188#%E4%BB%98%E9%8C%B2a-test-runnerpy%E3%81%AE%E3%82%BD%E3%83%BC%E3%82%B9)との差分を以下に示します。ソース全文は付録Aをご覧ください。

```python:diff
25a26
> from beepsound import BEEPSOUND
230a232
>     beepsound = BEEPSOUND()
365a368,380
>                 elif cmd[0] == "beepsound":
>                     if cmd[1] == "FurElise":
>                         beepsound.set_tempo(60)
>                         beepsound.FurElise()
>                     elif cmd[1] == "Menuet":
>                         beepsound.set_tempo(120)
>                         beepsound.Menuet()
>                     elif cmd[1] == "ToccataUndFugeInDMoll":
>                         beepsound.set_tempo(120)
>                         beepsound.ToccataUndFugeInDMoll()
>                     else:
>                         is_passed = False
> 
409d423
<                     led_fail()
410a425,427
>                     led_fail()
>                     beepsound.set_tempo(60)
>                     beepsound.FurElise()
416d432
<         led_pass()
417a434,436
>         led_pass()
>         beepsound.set_tempo(120)
>         beepsound.Menuet()
```

##4. おわりに
- [Software Tone Library](http://wiringpi.com/reference/software-tone-library/)のおかげで外付けの[メロディIC](https://www.google.co.jp/search?hl=ja&as_q=メロディIC)を使用せずにメロディを鳴らすことができました。
- トッカータとフーガ ニ短調はfailして終了というよりもゲームオーバーな印象が強く、選曲にセンスが問われると思いました。
- 任意の音を鳴らせるようになったことでエラーメッセージをモールス符号で発音するような応用もできそうと思いました。

##付録.A test-runner.pyのソース
```python:test-runner.py
#!/usr/bin/python3

#
# This software includes the work that is distributed
# in the Apache License 2.0
#

from time import sleep
import random
import sys
import codecs
import csv
import datetime
#import serial
#import pyvisa as visa
#import cv2
#from PIL import Image
#import pyocr
#import pyocr.builders
#import platform
import subprocess
from subprocess import PIPE
from AKI_I2C_AQM0802A import AKI_I2C_AQM0802A
import RPi.GPIO as GPIO
import bme280
from beepsound import BEEPSOUND

UNINITIALIZED = 0xdeadbeef
NUM_OF_SERVO = 6

# Akizuki IoT Learning HAT GPIO Number
LED_GR = 22
LED_Y = 27
LED_R = 17
LED_LCD_BACKLIGHT = 26

def serial_write(h, string):
    if h != UNINITIALIZED:
        string = string + '\n'
        string = str.encode(string)
        h.write(string)
        return True
    else:
        print("UART Not Initialized.")
        return False

def close_uart(h):
    if h != UNINITIALIZED:
        h.close()
    else:
        #print("UART Not Initialized.")
        pass

def open_dso():
    rm = visa.ResourceManager()
    resources = rm.list_resources()
    #print(resources)
    for resource in resources:
        #print(resource)
        try:
            dso = rm.open_resource(resource)
        except:
            print(resource, "Not Found.")
        else:
            print(resource, "Detected.")
            return dso

    #Throw an error to caller if none succeed.
    return dso

def open_cam(camera_number, width, height):
    h = cv2.VideoCapture(camera_number)
    h.set(cv2.CAP_PROP_FRAME_WIDTH, width)
    h.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
    return h

def close_cam(cam):
    if cam != UNINITIALIZED:
        cam.release()

def capture_cam(cam, filename):
    if cam != UNINITIALIZED:
        _, img = cam.read()
        cv2.imwrite(filename, img)
        return True
    else:
        print("CAM Not Ready.")
        return False

def crop_img(filename_in, v, h, filename_out):
    img = cv2.imread(filename_in, cv2.IMREAD_COLOR)
    v0 = int(v.split(':')[0])
    v1 = int(v.split(':')[1])
    h0 = int(h.split(':')[0])
    h1 = int(h.split(':')[1])
    img2 = img[v0:v1, h0:h1]
    cv2.imwrite(filename_out, img2)
    return True

def open_ocr():
    ocr = pyocr.get_available_tools()
    if len(ocr) != 0:
        ocr = ocr[0]
    else:
        ocr = UNINITIALIZED
        print("OCR Not Ready.")
    return ocr

def exec_ocr(ocr, filename):
    try:
        txt = ocr.image_to_string(
            Image.open(filename),
            lang = "eng",
            builder = pyocr.builders.TextBuilder()
        )
    except:
        print("OCR Fail.")
    else:
        return txt

def exec_labelimg(filename, label_string):
    if platform.system() == "Windows" :
        python = "python"
        grep = "findstr"
    else:
        python = "python3"
        grep = "grep"

    cmd = python + \
          " label_image.py \
          --graph=c:\\tmp\\output_graph.pb \
          --labels=c:\\tmp\\output_labels.txt \
          --input_layer=Placeholder \
          --output_layer=final_result \
          --image=" + filename \
          + "|" + grep + " " + label_string
    print(cmd)
    log = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
    ret = log.stdout.strip().decode("utf-8").split(" ")[1]
    return ret

def set_servo(uart, servo_id, servo_pos):
    if servo_id < 0 or servo_id >= NUM_OF_SERVO:
        print("Invalid Servo ID")
        return False

    if servo_pos < 0 or servo_pos > 180:
        print("Invalid Servo Position")
        return False

    if uart != UNINITIALIZED:
        serial_write(uart, "servoread")

        servo_position = uart.readline().strip().decode('utf-8')
        print(servo_position)

        # discard "OK"
        devnull = uart.readline().strip().decode('utf-8')

        current_pos = int(servo_position.split(' ')[servo_id])
        #print(servo_id, current_pos)

        if current_pos < servo_pos:
            start = current_pos +1
            stop  = servo_pos +1
            step  = 1
        else:
            start = current_pos -1
            stop  = servo_pos -1
            step  = -1

        for i in range(start, stop, step):
            command = "servo " + str(servo_id) + " " + str(i)
            print(command)
            serial_write(uart, command)
            # discard "OK"
            devnull = uart.readline().strip().decode('utf-8')
            sleep(0.2) # sec.

        return True
    else:
        print("UART Not Initialized.")
        return False

def led_init():
    GPIO.setmode(GPIO.BCM)

    GPIO.setwarnings(False)
    GPIO.setup(LED_GR, GPIO.OUT)
    GPIO.setup(LED_Y, GPIO.OUT)
    GPIO.setup(LED_R, GPIO.OUT)
    GPIO.setup(LED_LCD_BACKLIGHT, GPIO.OUT)

    GPIO.output(LED_GR, False)
    GPIO.output(LED_Y, True)
    GPIO.output(LED_R, False)
    GPIO.output(LED_LCD_BACKLIGHT, True)

def led_pass():
    GPIO.output(LED_GR, True)
    GPIO.output(LED_Y, False)
    GPIO.output(LED_R, False)

def led_fail():
    GPIO.output(LED_GR, False)
    GPIO.output(LED_Y, False)
    GPIO.output(LED_R, True)

def lcd_init():
    lcd = AKI_I2C_AQM0802A()
    lcd.Init_LCD()
    lcd.ClearDisplay()
    lcd.NewClearDisplay(0, 0)
    lcd.NewClearDisplay(1, 0)
    return lcd

def lcd_str(lcd, row, col, str):
    lcd.NewClearDisplay(row, 0)
    lcd.WritePos(row, col)
    lcd.WriteStr(str)

def main():
    is_passed = True
    val = str(UNINITIALIZED)
    fval = 0.0
    uart = UNINITIALIZED
    dso = UNINITIALIZED
    cam = UNINITIALIZED
    ocr = UNINITIALIZED
    led_init()
    lcd = lcd_init()
    beepsound = BEEPSOUND()

    if len(sys.argv) == 2:
        script_file_name = sys.argv[1] + ".csv"
        result_file_name = sys.argv[1] + "_result.csv"
    else:
        script_file_name = "script.csv"
        result_file_name = "result.csv"

    script_total_line = subprocess.check_output(['wc', '-l', script_file_name]).decode().split(' ')[0]
    lcd_str(lcd, 0, 0, script_total_line)

    with codecs.open(script_file_name, 'r', 'utf-8') as file:
        script = csv.reader(file, delimiter=',', lineterminator='\r\n', quotechar='"')

        with codecs.open(result_file_name, 'w', 'utf-8') as file:
            result = csv.writer(file, delimiter=',', lineterminator='\r\n', quotechar='"')

            script_line = 0
            for cmd in script:
                timestamp = datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S")
                script_line += 1
                lcd_str(lcd, 1, 0, str(script_line))
                print("########## " + str(script_line) + " ##########")
                print(cmd)

                if "#" in cmd[0]:
                    pass

                elif cmd[0] == "sleep":
                    sleep(float(cmd[1]))

                elif cmd[0] == "rusleep":
                    fval = random.uniform(float(cmd[1]), float(cmd[2]))
                    cmd.append(str(fval))
                    sleep(fval)

                elif cmd[0] == "open_uart":
                    if len(cmd) == 2:
                        dsrdtr_val = 1
                    else:
                        dsrdtr_val = int(cmd[2])
                    try:
                        uart = serial.Serial(cmd[1], 115200, timeout=1.0, dsrdtr=dsrdtr_val)
                    except:
                        is_passed = False

                elif cmd[0] == "send":
                    ret = serial_write(uart, cmd[1])
                    if ret == False:
                        is_passed = False

                elif cmd[0] == "rcvd":
                    try:
                        val = uart.readline().strip().decode('utf-8')
                        cmd.append(val)
                    except:
                        is_passed = False

                elif cmd[0] == "open_dso":
                    try:
                        dso = open_dso()
                    except:
                        is_passed = False

                elif cmd[0] == "dso":
                    try:
                        if "?" in cmd[1]:
                            val = dso.query(cmd[1]).rstrip().replace(",", "-")
                            cmd.append(val)
                        else:
                            dso.write(cmd[1])
                    except:
                        is_passed = False

                elif cmd[0] == "open_cam":
                    if len(cmd) == 2:
                        cam = open_cam(int(cmd[1]), 640, 480)
                    else:
                        cam = open_cam(int(cmd[1]), int(cmd[2]), int(cmd[3]))

                elif cmd[0] == "close_cam":
                    close_cam(cam)
                    cam = UNINITIALIZED

                elif cmd[0] == "capture_cam":
                    ret = capture_cam(cam, cmd[1])
                    if ret == False:
                        is_passed = False

                elif cmd[0] == "crop_img":
                    crop_img(cmd[1], cmd[2], cmd[3], cmd[4])

                elif cmd[0] == "open_ocr":
                    ocr = open_ocr()
                    if ocr == UNINITIALIZED:
                        is_passed = False

                elif cmd[0] == "exec_ocr":
                    try:
                        val = exec_ocr(ocr, cmd[1])
                    except:
                        is_passed = False
                    else:
                        cmd.append(str(val))

                elif cmd[0] == "exec_labelimg":
                    try:
                        val = exec_labelimg(cmd[1], cmd[2])
                    except:
                        is_passed = False
                    else:
                        cmd.append(str(val))

                elif cmd[0] == "set_servo":
                    ret = set_servo(uart, int(cmd[1]), int(cmd[2]))
                    if ret == False:
                        is_passed = False

                elif cmd[0] == "run":
                    ret = subprocess.run(cmd[1], shell=True, stdout=PIPE, stderr=PIPE, universal_newlines=True)
                    val = ret.stdout.strip()
                    print(ret)
                    if ret.returncode != 0:
                        is_passed = False

                elif cmd[0] == "env":
                    try:
                        val = bme280.readData(int(cmd[1],16))
                    except:
                        is_passed = False
                    else:
                        val = val.split(',')
                        for i in val:
                            cmd.append(i)

                elif cmd[0] == "beepsound":
                    if cmd[1] == "FurElise":
                        beepsound.set_tempo(60)
                        beepsound.FurElise()
                    elif cmd[1] == "Menuet":
                        beepsound.set_tempo(120)
                        beepsound.Menuet()
                    elif cmd[1] == "ToccataUndFugeInDMoll":
                        beepsound.set_tempo(120)
                        beepsound.ToccataUndFugeInDMoll()
                    else:
                        is_passed = False

                elif cmd[0] == "eval_str_eq":
                    if str(val) != str(cmd[1]):
                        is_passed = False

                elif cmd[0] == "eval_int_eq":
                    if int(val) != int(cmd[1]):
                        is_passed = False

                elif cmd[0] == "eval_int_gt":
                    if int(val) < int(cmd[1]):
                        is_passed = False

                elif cmd[0] == "eval_int_lt":
                    if int(val) > int(cmd[1]):
                        is_passed = False

                elif cmd[0] == "eval_dbl_eq":
                    if float(val) != float(cmd[1]):
                        is_passed = False

                elif cmd[0] == "eval_dbl_gt":
                    if float(val) < float(cmd[1]):
                        is_passed = False

                elif cmd[0] == "eval_dbl_lt":
                    if float(val) > float(cmd[1]):
                        is_passed = False

                else:
                    cmd.append("#")

                if is_passed == True:
                    cmd.append("OK")
                    cmd.insert(0,timestamp)
                    print(cmd)
                    result.writerow(cmd)
                else:
                    cmd.append("NG")
                    cmd.insert(0,timestamp)
                    print(cmd)
                    result.writerow(cmd)
                    close_uart(uart)
                    close_cam(cam)
                    print("FAIL")
                    led_fail()
                    beepsound.set_tempo(60)
                    beepsound.FurElise()
                    sys.exit(1)

    if is_passed == True:
        close_uart(uart)
        close_cam(cam)
        print("PASS")
        led_pass()
        beepsound.set_tempo(120)
        beepsound.Menuet()
        sys.exit(0)

main()
```

##付録B. Lチカで始めるテスト自動化・記事一覧
1. [Lチカで始めるテスト自動化](https://qiita.com/pbjpkas/items/fd4bd2689deda2d793aa)
2. [Lチカで始めるテスト自動化(2)テストスクリプトの保守性向上](https://qiita.com/pbjpkas/items/337bce392281ee5f0526)
3. [Lチカで始めるテスト自動化(3)オシロスコープの組込み](https://qiita.com/pbjpkas/items/b80b7e199a5aed4688a4)
4. [Lチカで始めるテスト自動化(4)テストスクリプトの保守性向上(2)](https://qiita.com/pbjpkas/items/a02c4128baf5a3f2a46c)
5. [Lチカで始めるテスト自動化(5)WebカメラおよびOCRの組込み](https://qiita.com/pbjpkas/items/9af9e6441e1e20c5909e)
6. [Lチカで始めるテスト自動化(6)AI(機械学習)を用いたPass/Fail判定](https://qiita.com/pbjpkas/items/405d55f809da1221b867)
7. [Lチカで始めるテスト自動化(7)タイムスタンプの保存](https://qiita.com/pbjpkas/items/8e3c4330f4a81d30bd53)
8. [Lチカで始めるテスト自動化(8)HDMIビデオキャプチャデバイスの組込み](https://qiita.com/pbjpkas/items/58f84c347e44fb9d5635)
9. [Lチカで始めるテスト自動化(9)6DoFロボットアームの組込み](https://qiita.com/pbjpkas/items/7a4cb4c59886b1fd2cf9)
10. [Lチカで始めるテスト自動化(10)6DoFロボットアームの制御スクリプトの保守性向上](https://qiita.com/pbjpkas/items/c011d7ad6f98bc1458d5)
11. [Lチカで始めるテスト自動化(11)ロボットアームのコントローラ製作](https://qiita.com/pbjpkas/items/fa6d2956ea3dd18b3584)
12. [Lチカで始めるテスト自動化(12)書籍化の作業メモ](https://qiita.com/pbjpkas/items/e76a763de7f496d5430c)
13. [Lチカで始めるテスト自動化(13)外部プログラムの呼出し](https://qiita.com/pbjpkas/items/717b2aba39798d8cab1d)
14. [Lチカで始めるテスト自動化(14)sleepの時間をランダムに設定する](https://qiita.com/pbjpkas/items/4f023917f31f6096eb6b)
15. [Lチカで始めるテスト自動化(15)Raspberry Pi Zero WHでテストランナーを動かして秋月のIoT学習HATキットに進捗を表示する](https://qiita.com/pbjpkas/items/a2e7551ae10f7dce2505)
16. [Lチカで始めるテスト自動化(16)秋月のIoT学習HATキットにBME280を接続してテスト実行環境の温度・湿度・気圧を取得する](https://qiita.com/pbjpkas/items/0a48ca71a2de0042e188)
17. [Lチカで始めるテスト自動化(17)コマンド制御のBLE Keyboard & MouseをM5Stackで製作しiOSアプリをテストスクリプトで操作する](https://qiita.com/pbjpkas/items/3df58743644128a11b9e)

電子書籍化したものを[技術書典](https://techbookfest.org/)で頒布しています。
1. [Lチカで始めるテスト自動化](https://techbookfest.org/product/5661312486998016)
2. [Lチカで始めるテスト自動化 -2- リレー駆動回路の設計](https://techbookfest.org/product/5299462253576192) (※書き下ろし)
3. [Lチカで始めるテスト自動化 -3- Raspberry Pi Zero WHとIoT学習HATキットで作るテストランナー](https://techbookfest.org/product/6323836549070848)