Raspberry Pi Pythonで複数のGPIOピンに効率的よくシェルスクリプトを割当てる

#### Python で Raspberry Pi の 複数のGPIOピンに効率的よくシェルスクリプトを割当てる方法を紹介いたします。

以下、Raspberry Pi は ラズパイとして話を進めます。

下記のようなラズパイを組み込んだシステムのボタン処理の効率化についての説明になります。

![GPIO_assign_process_overview.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583120/08189749-2b21-e079-3c2f-5fccb7cba96f.jpeg)

### 実行環境
+ **ラズパイゼロ(WH)、ラズパイ３、４ ※ラズパイPicoは対象外**
+ **システムライブラリ pipgio がインストール済みであること**
+ **python仮想環境からpythonを実行する (python-3.7.2 〜 3.8.x)**
+ **pythonライブラリ pigpio がインストール済みであること**


### リソースの構成
```
raspi_pigpio
├── bin
│   ├── buttons_startup.sh
│   ├── kill_display_process.sh
│   ├── restart_udpmon.sh
│   ├── show_weather_forecast.sh
│   └── pigpio
│       ├── ButtonStartServices.py
│       ├── ShowWeatherForecast.py
│       └── conf
│       　  └── conf_buttonstart.json
└── datas
    └── WeatherForecast.txt
```


## 1. 実装方法

### 1-1. GPIOピンと起動するシェルスクリプトの対応付け

```conf_buttonstart.json
{
  "actions": [
    "showWeatherForecast",
    "killDisplayAndMelodyProcess",
    "restartUdpmon"
  ],
  "showWeatherForecast": {
    "PIN": 18,
    "PUD_down": true,
    "enable": true,
    "script": "/home/pi/bin/show_weather_forecast.sh"
  },
  "killDisplayAndMelodyProcess": {
    "PIN": 12,
    "PUD_down": true,
    "enable": true,
    "script": "/home/pi/bin/kill_display_process.sh"
  },
  "restartUdpmon": {
    "PIN": 24,
    "PUD_down": true,
    "enable": true,
    "script": "/home/pi/bin/restart_udpmon.sh"
  }
}
```

### 1-2. ボタンサービス実行シェルスクリプト

```buttons_startup.sh
#!/bin/bash

. $HOME/py_venv/py37_pigpio/bin/activate

python $HOME/bin/pigpio/ButtonStartServices.py

deactivate
```

### 1-3. Pythonスクリプト

#### 1-3-1. インポート・グローバル変数定義

```ButtonStartServices.py
import logging
import os
import signal
import subprocess
import time
import threading
import pigpio

import util.file_util as FU
from log import logsetting

"""
Buttons startup srcipt on service
"""

base_dir = os.path.abspath(os.path.dirname(__file__))
logger = logsetting.create_logger("service_buttonstart")


# pigpio オブジェクト
pi = None
# conf_buttonstart.jsonを読み込んだ辞書オブジェクト
conf_app = None
# GPIOピンコールバックリスト
cb_list = []
isLogLevelDebug = False
# Prevent chattering
prev_tick = 0
THRESHOLD_DIFF_TICK = 500000
```

#### 1-3-2. シャットダウンフックとクリーンアップ処理

```python
def detect_signal(signum, frame):
    """
    Detect shutdown, and execute cleanup.
    :param signum: Signal number
    :param frame: frame
    """
    logger.info("signum: {}, frame: {}".format(signum, frame))
    if signum == signal.SIGTERM:
        # signal shutdown or kill
        cleanup()
        # Current process terminate
        exit(0)


def cleanup():
    for _cb in cb_list:
        _cb.cancel()
    pi.stop()
```

#### 1-3-3. サブプロセス起動処理

```python
def subproc_start(script):
    script_name = os.path.basename(script)
    try:
        logger.info("Subprocess {} start.".format(script_name))
        exec_status = subprocess.run([script])
        logger.info("Subprocess {} terminated: {}".format(script_name, exec_status))
    except Exception as ex:
        logger.warning(ex)
```

#### 1-3-4. ボタン(GPIOピン)押下時の処理  

+ (A) ボタン押下時のチャタリング検知処理  
+ (B) アクションリストのループ処理  
    **アクションピンとボタンのGPIOピンが一致したら、対応するシェルスクリプトをサブプロセスとして起動する**

```python
def change_actions(gpio_pin, level, tick):
    # (A) ボタン押下時のチャタリング検知処理
    global prev_tick
    logger.info("pin: {}, level: {}, tick: {}".format(gpio_pin, level, tick))
    if prev_tick != 0:
        tick_diff = pigpio.tickDiff(prev_tick, tick)
        if isLogLevelDebug:
            logger.debug("tick_diff:{}".format(tick_diff))
        if tick_diff < THRESHOLD_DIFF_TICK:
            logger.info("tick_diff:{} < {} return".format(tick_diff, THRESHOLD_DIFF_TICK))
            return

    prev_tick = tick
    # (B) ボタン(GPIOピン)に割り当てられたシェルスクリプトを起動する処理
    for action in conf_app["actions"]:
        button_service = conf_app[action]
        act_pin = button_service["PIN"]
        if act_pin == gpio_pin and button_service["enable"]:
            script = button_service["script"]
            script_name = os.path.basename(script)
            subprc_thread = threading.Thread(target=subproc_start, args=(script,))
            logger.info("Subprocess {} thread start".format(script_name))
            subprc_thread.start()
```

#### 1-3-5. GPIOピンと対応するコールバックの関連付け処理

　**設定ファイルにピン設定と起動するシェルスクリプトのリストを定義することにより、GPIOピンの初期化とコールバックの定義が効率的にできるようになります。**  
 ※処理するボタンが増えてもこの関数は修正なしにそのまま使えます

```python
def setup_gpio():
    global cb_list
    for action in conf_app["actions"]:
        button_service = conf_app[action]
        if isLogLevelDebug:
            logger.debug("{}: {}".format(action, button_service))
        act_pin = button_service["PIN"]
        act_PUD_down = button_service["PUD_down"]
        pi.set_mode(act_pin, pigpio.INPUT)
        # PullUp か PullDownによりピンの設定変える
        if act_PUD_down:
            pi.write(act_pin, pigpio.LOW)
            pi.set_pull_up_down(act_pin, pigpio.PUD_DOWN)
            cb_action = pi.callback(act_pin, pigpio.RISING_EDGE, change_actions)
        else:
            pi.write(act_pin, pigpio.HIGH)
            pi.set_pull_up_down(act_pin, pigpio.PUD_UP)
            cb_action = pi.callback(act_pin, pigpio.FALLING_EDGE, change_actions)
        # Callback
        cb_list.append(cb_action)
    if isLogLevelDebug:
        logger.debug("Callback list: {}".format(cb_list))
```

#### 1-3-6. メイン処理

+ pigpioオブジェクト生成
+ シャットダウンフックの定義
+ GPIOピンと起動するシェルスクリプトの対応付け設定ファイル読込み
+ GPIOピンの初期化とコールバックリスト生成
+ 無限ループ (1秒間スリープする)

**このスクリプトはシステムサービスから呼び出されることを前提に作られています。**

```python
if __name__ == '__main__':
    pi = pigpio.pi()
    if not pi.connected:
        logger.warning("pigpiod not stated!")
        exit(1)

    # Check shutdown signal
    signal.signal(signal.SIGTERM, detect_signal)

    isLogLevelDebug = logger.getEffectiveLevel() <= logging.DEBUG
    # GPIOピンと起動するシェルスクリプトの対応付け設定ファイルを読み込む
    conf_app = FU.read_json(os.path.join(base_dir, "conf", "conf_buttonstart.json"))
    if isLogLevelDebug:
        logger.debug(conf_app)

    # GPIOピンと対応するコールバックの関連付け処理
    setup_gpio()
    try:
        # 無限ルーブ
        while True:
            time.sleep(1.0)
    except KeyboardInterrupt:
        pass
    finally:
        cleanup()
```


#### 【参考】システムサービス設定ファイル

```button_start.service
[Unit]
Description=Button start Specific application subprocess.
After=newtwork-online.target

[Service]
Type=simple
ExecStart=/home/pi/bin/buttons_startup.sh
User=pi

[Install]
WantedBy=multi-user.target
```

ボタン起動開始サービスの登録処理とサービス起動

```bash
$ sudo cp button_start.service /etc/systemd/system
$ sudo systemctl enable button_start.service
$ sudo systemctl start button_start.service
```

pythonアプリケーションのシステムサービス化に関しては下記 Qiita投稿記事をご覧ください。
[(Qiita) RaspberryPi Pythonアプリケーションをシステムサービス化する](https://qiita.com/pipito-yukio/items/ac2657adc45c453eea55)



### 2. ボタン押下時に起動されるシェルスクリプト

#### 2-1. 天気予報表示

```show_weather_forecast.sh
#!/bin/bash

# Prevent double execute.
pid_file="${PATH_DATAS}/.forecast"
if [ -e "$pid_file" ]; then
   exit 0
fi

touch "$pid_file"
. $HOME/py_venv/py37_pigpio/bin/activate
python $HOME/bin/pigpio/ShowWeatherForecast.py

deactivate
```

##### 【参考】ソースのみ掲載します

pythonスクリプトが読み込むファイルの内容は以下の通りです。  
※1 cron で定期的に気象情報サイトからデータを取得し先頭に追記する
※2 pythonスクリプトは先頭行を表示する。

```WeatherForecast.txt
2023-11-29 12:00:00,１１月２９日	札幌市豊平区	雪のち曇	−３〜−１℃
2023-11-29 10:00:00,１１月２９日	札幌市豊平区	雪のち曇	−３〜０℃
2023-11-29 08:00:00,１１月２９日	札幌市豊平区	晴時々雪	−３〜０℃
2023-11-29 04:00:00,１１月２９日	札幌市豊平区	曇時々雪	−２〜０℃
```


```ShowWeatherForecast.py
import logging
import os
import subprocess
from urllib.parse import quote_plus
import util.file_util as FU
from log import logsetting

"""
当日の最新の天気予報をサブプロセスでOLEDに出力する
[ファイル]
  $PATH_DATAS/WeatherForecast.txt
"""

logger = logsetting.create_logger("main_app")

isLogLevelDebug = False

if __name__ == '__main__':
    isLogLevelDebug = logger.getEffectiveLevel() <= logging.DEBUG
    home_datas_dir = os.environ.get("PATH_DATAS", "/home/pi/datas")
    conf_app = FU.read_json(os.path.join(home_datas_dir, "conf_weatherforecast.json"))
    # 天気予報ファイル
    filename = conf_app["filename"]
    file_path = os.path.join(home_datas_dir, filename)
    encoded = ''
    if os.path.exists(file_path):
        today_list = FU.read_text(file_path)
        if len(today_list) > 0:
            # 先頭が最新
            latest = today_list[0]
            if isLogLevelDebug:
                logger.debug("latest: {}".format(latest))
            # 末尾の出力メッセージ
            latest = latest.split(",")
            encoded = quote_plus(latest[1])
            if isLogLevelDebug:
                logger.debug(encoded)

    script = conf_app["displayMessage"]["script"]
    if len(encoded) > 0:
        exec_status = subprocess.run([script, "--encoded-message", encoded, "--has-list"])
    else:
        # 取得データが無い場合のメッセージ
        encoded = conf_app["displayMessage"]["emptyData"]
        encoded = quote_plus(encoded)
        exec_status = subprocess.run([script, "--encoded-message", encoded])
    logger.info("Subprocess DisplayMessage terminated: {}".format(exec_status))
```


#### 2-2. 表示ボタン起動のプロセス削除  

```kill_display_process.sh
#!/bin/bash

bak_ifs=$IFS
IFS=

# メロディー鳴動プロセス削除
for ps_line in $(ps aux | grep PlayMelody | grep -v grep)
do
   echo $ps_line | awk '{ print $2 }' | xargs kill
done

sleep 1

# OLED出力示プロセス削除 ※天気予報表示
for ps_line in $(ps aux | grep DisplayMessageToOLED | grep -v grep)
do
   echo $ps_line | awk '{ print $2 }' | xargs kill
done
IFS=$bak_ifs

PATH_DATAS=/home/pi/datas
pid_forecast="${PATH_DATAS}/.forecast"
if [ -e "$pid_forecast" ]; then
   rm -f "$pid_forecast"
fi

pid_display="${PATH_DATAS}/.display"
if [ -e "$pid_display" ]; then
   rm -f "$pid_display"
fi
```

【参考】下記 ```DisplayMessageToOLED.py``` の全てのプロセスを削除する

```
$ ps aux | grep "\.py" | grep -v grep
root       281  0.0  2.2  24808  9996 ?        Sl   08:46   0:04 python /home/pi/bin/pigpio/SwitchToPoweroff.py --poweroff-pin 21 --ledblink-pin 20
pi         336  0.0  2.2  24536  9760 ?        Sl   08:46   0:03 python /home/pi/bin/pigpio/ButtonStartServices.py
pi         346  0.0  3.2  71900 14204 ?        Sl   08:46   0:03 python /home/pi/bin/pigpio/UDPClientFromWeatherSensor.py --udp-port 2222 --brightness-pin 17
pi         366  0.0  4.4  62480 19576 ?        Sl   08:46   0:14 python /home/pi/webapp/run.py
pi        1292  0.0  3.7  74172 16624 ?        Sl   12:29   0:03 python /home/pi/bin/pigpio/DisplayMessageToOLED.py --encoded-message %E9%9B%BB%E6%B1%A0%E5%88%87%E3%82%8C12%3A29
pi        1313  0.0  3.7  74172 16544 ?        Sl   12:41   0:02 python /home/pi/bin/pigpio/DisplayMessageToOLED.py --encoded-message %E9%9B%BB%E6%B1%A0%E5%88%87%E3%82%8C12%3A41
pi        1333  0.0  3.8  74172 16772 ?        Sl   12:53   0:02 python /home/pi/bin/pigpio/DisplayMessageToOLED.py --encoded-message %E9%9B%BB%E6%B1%A0%E5%88%87%E3%82%8C12%3A53
```

#### 2-3. UDPモニターサービス再起動

```kill_display_process.sh
#!/bin/bash

my_passwd=your_raspi_passwd
echo $my_passwd | {
   sudo --stdin systemctl stop udp-weather-mon.service
   sudo systemctl start udp-weather-mon.service
}
```

### 3. 天気予報表示プロセス起動ボタン押下

白いボタンを押さない限り延々と表示を繰り返します。
白いボタンを押すと天気予報表示プロセスが削除され表示が消えます。

![DisplayWeatherToOLED.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3583120/d3510134-4b37-2d1e-6eeb-be75464e25bf.jpeg)

#### 実行ログ

```
# 黄色いボタン押下
2023-11-29 14:04:48 INFO ButtonStartServices.py(61)[change_actions] pin: 18, level: 1, tick: 2019127849
2023-11-29 14:04:48 INFO ButtonStartServices.py(78)[change_actions] Subprocess show_weather_forecast.sh thread start
2023-11-29 14:04:48 INFO ButtonStartServices.py(52)[subproc_start] Subprocess show_weather_forecast.sh start.
# 白いボタン押下: 天気予報表示プロセス削除
2023-11-29 14:05:12 INFO ButtonStartServices.py(61)[change_actions] pin: 12, level: 1, tick: 2043146570
2023-11-29 14:05:12 INFO ButtonStartServices.py(78)[change_actions] Subprocess kill_display_process.sh thread start
2023-11-29 14:05:12 INFO ButtonStartServices.py(52)[subproc_start] Subprocess kill_display_process.sh start.
2023-11-29 14:05:14 INFO ButtonStartServices.py(54)[subproc_start] Subprocess kill_display_process.sh terminated: CompletedProcess(args=['/home/pi/bin/kill_display_process.sh'], returncode=0)
2023-11-29 14:05:14 INFO ButtonStartServices.py(54)[subproc_start] Subprocess show_weather_forecast.sh terminated: CompletedProcess(args=['/home/pi/bin/show_weather_forecast.sh'], returncode=0)```
```

### 4. 結論

**ラズパイでボタンに特定のシェルスクリプトを実行させたい場合、GPIOピン設定とシェルスクリプトを紐付けたた設定ファイルを作るとGPIOの初期化処理が効率的になります。**

**ボタンが増えた場合(逆に減った場合)でも設定ファイルを編集するだけですみます。  
※但しボタン開始サービスの再起動は必要**

最初の画像で紹介したシステムのソースコード(システムサービス登録含む)は下記GitHubリポジトリで公開しております。

[GitHub(pipito-yukio) home_weather_sensors/installer/src/raspi_pigpio](https://github.com/pipito-yukio/home_weather_sensors/tree/master/installer/src/raspi_pipgio)