ADPiとアナログ距離センサでの定期計測：Raspberry Piメタルケースセットの活用法

[4GPi]:https://mechatrax.com/products/4gpi/
[slee-Pi 3]:https://mechatrax.com/products/slee-pi/
[Pi-protect]:https://mechatrax.com/products/pi-protect/
[ADPi]:https://mechatrax.com/products/adpi/
[ADPi Pro]:https://mechatrax.com/products/adpi/
[機能拡張モジュール]:https://mechatrax.com/products/
[イプシム]:https://ipsim.net/
[USB-シリアル変換ケーブル]:https://store.mechatrax.com/product.php?id=38
[弊社]:https://mechatrax.com/
[メカトラックス株式会社]:https://mechatrax.com/
[SORACOM]:https://soracom.jp/
[SORACOM Napter]:https://soracom.jp/services/napter/
[Remote.It]:https://ja.remote.it/
[@hayat00]:https://qiita.com/hayat00
[Raspberry Pi Imager]:https://www.raspberrypi.com/software/
[弊社オンラインストア]:https://store.mechatrax.com/
[Tera Term]:https://ja.osdn.net/projects/ttssh2/
[OSイメージ]:https://mechatrax.com/data/4gpi/
[2段用]:https://store.mechatrax.com/product.php?id=62
[3段用]:https://store.mechatrax.com/product.php?id=63
[メタルケースセット]:https://store.mechatrax.com/list.php?c_id=22
[メタルケースセット（4GPi + slee-Pi 3）]:https://store.mechatrax.com/product.php?id=70
[メタルケースセット（ADPi Pro + slee-Pi 3）]:https://store.mechatrax.com/product.php?id=77

# はじめに

今回は[弊社]製品の[メタルケースセット（ADPi Pro + slee-Pi 3）]を使用し、アナログ出力型の測距モジュール GP2Y0A21YK で計測した距離を、ラズパイ上のWebサーバーにChart.jsを使用し、リアルタイムで表示する方法をご紹介します。

[メタルケースセット（ADPi Pro + slee-Pi 3）]には、Raspberry Pi 4 Model Bと、高精度AD変換モジュールの[ADPi Pro]と、電源管理や時刻の保持が可能な[slee-Pi 3]が搭載されています。さらに、環境構築済みSDカードやACアダプタ等も付属しており、すぐにお使いいただくことが可能です。


# 使うもの
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3113974/c5fe1bea-e425-4ca0-a514-bedfa81e3a84.png)

- [メタルケースセット（ADPi Pro + slee-Pi 3）]


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3113974/86314434-b24e-4b2c-bab7-df152866fdb6.png)
- 測距モジュール GP2Y0A21YK
    - [ADPi Pro]への取り付けのために、数cm程度の配線（オス-オスのジャンパ線等でも構いません）と、データシートを参考に10μFのコンデンサもご用意ください。
    - [データシート](https://jp.sharp/products/device/doc/opto/gp2y0a21yk_e.pdf)と[アプリケーションノート](https://jp.sharp/products/device/doc/opto/gp2y0a_gp2y0d_series_appl_e.pdf)はそれぞれこちらを参照してください。
- 開発用のPC、ラズパイへのログイン用の[USB-シリアル変換ケーブル]など

# ハードウェアの準備
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3113974/df09710a-136a-42a8-9b9e-3507e1cc6d89.png)
今回は[ADPi Pro]のCH1に測距モジュールを接続します。メタルケースセットのカバーを外し、CH1のターミナルを外します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3113974/45bbd713-9cd8-471d-b7e5-b169d5aeb2cc.png)
図の向きのとき、上から12V、CH1+、CH1-、GNDとなっています。測距モジュールの[データシート](https://jp.sharp/products/device/doc/opto/gp2y0a21yk_e.pdf)を参考に接続してください。また、CH1-とGNDは用意した配線で短絡し、12VとGNDの間には10μFのコンデンサを接続します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3113974/c035e4ae-90bb-45bf-97b1-117ca8597308.png)
図を参考に[ADPi Pro]のCH1にターミナルを取り付け、付属の「DCジャック - XHP-2コネクタ変換ハーネス」を[slee-pi 3]のCN1に取り付け、付属のACアダプタから電源を投入します。

# ソフトウェアの準備
電源を投入し、ラズパイが起動したらPCからログインしてください。初期ログイン情報は[こちら](https://github.com/mechatrax/pi-protect/wiki/OS%E3%82%A4%E3%83%A1%E3%83%BC%E3%82%B8#%E5%88%9D%E6%9C%9F%E3%83%AD%E3%82%B0%E3%82%A4%E3%83%B3%E6%83%85%E5%A0%B1%E3%82%AB%E3%83%BC%E3%83%89)を参照してください。ラズパイへのログイン方法については[こちら](https://qiita.com/hayat00/items/0d586f5500e998ab4d2e#raspberry-pi%E3%81%B8%E3%81%AE%E3%83%AD%E3%82%B0%E3%82%A4%E3%83%B3)などを参照してください。
ログインができたら、PCと同一のLAN内にラズパイを接続してください。

任意のディレクトリに移動し、以下の2つのスクリプト（get_adpi.py と web_server.py）を作成します。今回は、`/home/mtx/adpi-web-monitor`に作成しました。ディレクトリを変更する場合はそれぞれのスクリプトの`DIR＝`も変更してください。

```get_adpi.py
import spidev
import smbus
import adpi
import sys
import time
import csv
import os
from datetime import datetime
from time import sleep

DIR = "/home/mtx/adpi-web-monitor"

INTERVAL_SEC = 1 # 計測のインターバル（秒）
ADPI_CH = "1" # 使用するADPiのチャンネル

RAW_OFFSET = (1 << 23) # AD7794の24bit ADC出力（2の補数形式）で0Vに相当する値（=8388608）
RAW_SCALE = (
    0.000596040,
    0.000298020,
    0.000149010,
    0.000074500,
    0.000037250,
    0.000018620,
    0.000009310,
    0.000004650,
) # AD7794のゲインごとの1bitあたりの電圧換算係数（V/LSB）
TEMP_VREF = 1.17 # AD7794内部温度センサ用の基準電圧（V）

def v2k(rate, val):
    for k, v in rate.items():
        if v == val:
            return k

def set_calib(dev):
    g = dev.adc.gain['1']
    r = dev.adc.rate['470']
    bias = dev.load_bias(g)
    scale = dev.load_scale(g)
    _, r = dev.read_mode()
    for i in range(dev.channels):
        dev.write_configuration(g, i)
        dev.write_mode(dev.adc.mode['idle'], r)
        dev.write_offset(bias[i])
        dev.write_mode(dev.adc.mode['idle'], r)
        dev.write_fullscale(scale[i])

def single_conversion(dev, ch):
    c = dev.adc.channel[ch]
    g, _ = dev.read_configuration()
    dev.write_configuration(g, c)
    _, r = dev.read_mode()
    dev.write_mode(dev.adc.mode['single'], r)
    rate = v2k(dev.adc.rate, r)

    while True:
        sleep(2 * 1.0 / float(rate))
        if not dev.read_status() & 0x80:
            break

    raw = dev.read_data()
    return raw, g

def get_voltage(dev, ch):
    raw, g = single_conversion(dev, ch)
    vol = RAW_SCALE[g] * (raw - RAW_OFFSET)
    return vol

def calc_distance(v):
    d = 22 / (v - 0.13) * 1000
    return d

def ctrl_output(dev, ch, state):
    c = dev.adc.channel[ch]
    dev.set_output(c, state)

if __name__ == "__main__":
    spibus = 0
    spics = 0
    eeprombus = 1
    eepromaddr = 0x57
    gpiobus = 1
    gpioaddr = 0x27
    spi = spidev.SpiDev()
    i2c = smbus.SMBus(eeprombus)

    timestamp = datetime.now().strftime("%Y%m%d_%H%M")
    csv_path = f"{DIR}/sensor_data_{timestamp}.csv"

    try:
        spi.open(spibus, spics)
        spi.mode = 0b11
        spi.max_speed_hz = 1000000
        ad = adpi.ADPiPro(spi, i2c, eepromaddr, gpioaddr)
        ctrl_output(ad, ADPI_CH, True)
        set_calib(ad)
        sleep(0.5)

        with open(csv_path, "w", newline="") as f:
            writer = csv.writer(f)
            writer.writerow(["timestamp", "distance[cm]"])

        link_path = f"{DIR}/sensor_data_latest.csv"
        if os.path.islink(link_path) or os.path.exists(link_path):
            os.remove(link_path)
        os.symlink(csv_path, link_path)

        while True:
            voltage = get_voltage(ad, ADPI_CH)
            distance = calc_distance(voltage)
            with open(csv_path, "a", newline="") as f:
                writer = csv.writer(f)
                writer.writerow([
                    datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                    "{:.9f}".format(distance)
                ])
            sleep(INTERVAL_SEC)

    except (IndexError, ValueError):
        sys.exit(2)
    finally:
        try:
            ctrl_output(ad, ADPI_CH, False)
        except:
            pass
        spi.close()
```

:::note info



<!-- open属性なし -->
<details><summary>def calc_distance(v):での換算式について</summary>


- [データシート](https://jp.sharp/products/device/doc/opto/gp2y0a21yk_e.pdf)の以下のグラフより、出力電圧と計測距離の逆数は10cmから80cmの範囲で直線的な比例関係があると判断しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3113974/4a301e34-c208-43c8-ac82-a72ae4e3cdd2.png)

- 同じく[データシート](https://jp.sharp/products/device/doc/opto/gp2y0a21yk_e.pdf)の表から、80cmのときに0.4V、10cmのときに2.3V（0.4V + 1.9V）と読み取りました。  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3113974/33f59170-8de9-440b-b3f0-1d67df8fe555.png)

- 以上より、出力電圧 `v` と距離 `d` の関係式は次のように定義しました：`d = 22 / (v - 0.13)`

※本換算式はセンサの代表特性に基づく近似値であり、実環境では誤差が生じる可能性があります。実運用時には実測値に基づいて補正を行ってください。

</details>

:::


```web_server.py
from flask import Flask, jsonify, render_template_string
import csv
import os

app = Flask(__name__)

DIR = "/home/mtx/adpi-web-monitor"
CSV_LATEST_PATH = f"{DIR}/sensor_data_latest.csv"
TITLE = "ADPi Monitor"

DATA_NUM = 10 # 表示するデータの件数
REFRESH_INTERVAL = 500  # 画面の更新（ミリ秒）

@app.route("/api/latest")
def api_latest():
    with open(CSV_LATEST_PATH) as f:
        reader = list(csv.DictReader(f))
        rows = reader[-DATA_NUM:]
    return jsonify(rows)

@app.route("/")
def index():
    return render_template_string(f"""
<html>
<head>
    <meta charset="UTF-8">
    <title>{TITLE}</title>
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body>
    <h1>{TITLE}</h1>
    <canvas id="chart" width="960" height="540"></canvas>
    <script>
        const REFRESH_INTERVAL = {REFRESH_INTERVAL}; 

        async function fetchData() {{
            const res = await fetch("/api/latest");
            const data = await res.json();

            const labels = data.map(d => d["timestamp"]);
            const distances = data.map(d => parseFloat(d["distance[cm]"]));

            const ctx = document.getElementById('chart').getContext('2d');
            if (window.myChart) {{
                window.myChart.data.labels = labels;
                window.myChart.data.datasets[0].data = distances;
                window.myChart.update();
            }} else {{
                window.myChart = new Chart(ctx, {{
                    type: 'line',
                    data: {{
                        labels: labels,
                        datasets: [{{
                            label: 'Distance (cm)',
                            data: distances,
                            borderWidth: 2
                        }}]
                    }},
                    options: {{
                        responsive: true,
                        scales: {{
                            y: {{
                                min: 0,
                                max: 80,
                                beginAtZero: true
                            }}
                        }}
                    }}
                }});
            }}
        }}

        fetchData();
        setInterval(fetchData, REFRESH_INTERVAL); 
    </script>
</body>
</html>
""")


if __name__ == '__main__':
    app.run(host="0.0.0.0", port=8000)
```



:::note warn



<!-- open属性なし -->
<details><summary>Webサーバーのセキュリティ対策について</summary>


本記事のWebサーバーはセキュリティ対策を考慮していません。  
**インターネット越しのアクセスは想定していないため、同一のLAN内での利用に限定してください。**  
外部公開を行う場合は各種セキュリティ対策の実施をお願いいたします。

</details>

:::









次に、Pythonモジュールを実行する仮想環境を構築します。`/home/mtx/adpi-web-monitor`で次のコマンドを実行してください。
```
python3 -m venv venv
source venv/bin/activate
sudo apt install python3-dev
pip install flask spidev smbus
cp -r /usr/lib/python3/dist-packages/adpi ~/adpi-web-monitor/venv/lib/python3.11/site-packages/
```

仮想環境の構築が完了したら下記のコマンドで仮想環境を終了します。
```
deactivate
```

# ラズパイ起動時に作成したスクリプトを自動実行する

続いて下記の2つのサービスファイル（ get_adpi.service と web_server.service ）を作成します。ここでは、`/home/mtx/adpi-web-monitor`に作成しています。

```get_adpi.service
[Unit]
After=network.target

[Service]
WorkingDirectory=/home/mtx/adpi-web-monitor
ExecStart=/home/mtx/adpi-web-monitor/venv/bin/python3 get_adpi.py
Restart=on-failure
RestartSec=10

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

```web_server.service
[Unit]
After=network.target get_adpi.service
Requires=get_adpi.service

[Service]
WorkingDirectory=/home/mtx/adpi-web-monitor
ExecStart=/home/mtx/adpi-web-monitor/venv/bin/python3 web_server.py
Restart=on-failure
RestartSec=10

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




下記のコマンドを実行し、ラズパイの起動時に自動実行されるようにします。
```
sudo cp /home/mtx/adpi-web-monitor/get_adpi.service /etc/systemd/system/
sudo cp /home/mtx/adpi-web-monitor/web_server.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable get_adpi.service web_server.service
```

サービスファイルの設定が完了したらシャットダウンします。
```
sudo shutdown -h now
```

# 起動の確認

メタルケースセットのカバーを閉じます。[ADPi Pro]のCH1のターミナルを一度外し、カバーを閉じてから再度取り付けてください。
今回は、測距モジュールをカバーを留めるねじと共締めしてケースに固定しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3113974/17e35579-88d7-42c4-9929-44f68d271e76.png)


ラズパイを起動し、下記のようにIPアドレス（192.168.YY.ZZZ）を確認します。続いてWEBブラウザから確認したIPアドレスを使用し、`192.168.YY.ZZZ:8000`にアクセスしてください。

```
$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000

...

2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP group default qlen 1000
    link/ether XX:XX:XX:XX:XX:XX brd ff:ff:ff:ff:ff:ff
    inet 192.168.YY.ZZZ/24 brd 192.168.XX.255 scope global dynamic noprefixroute eth0
       valid_lft 12671sec preferred_lft 12671sec
    inet6 XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX/64 scope global dynamic noprefixroute
       valid_lft 14305sec preferred_lft 14305sec
    inet6 fe80::XXXX:XXXX:XXXX:XXXX/64 scope link noprefixroute
       valid_lft forever preferred_lft forever

...
```


下記のように計測距離をWEBブラウザに表示し、測距モジュールの前にものを置くと値が変化する様子を確認できました。

![PXL_20250529_083555797.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3113974/c365b6f6-1c47-4e76-a59a-fae5be810ecd.gif)



# 注意事項

- 作成した2つのスクリプト（ get_adpi.py と web_server.py ）は、仮想環境で実行してください。仮想環境を有効にするには`source venv/bin/activate`を実行します。仮想環境を終了するには`deactivate`を実行します。


- サービスを使わずに動作確認を行いたい場合は、`python3 get_adpi.py &`のようにバックグラウンドで  get_adpi.py を実行してから `python3 web_server.py` を実行してください。バックグラウンドで動作中の get_adpi.py を停止するには`pkill -f get_adpi.py`を実行する方法などがあります。



- get_adpi.py の実行中に強制終了や異常終了した場合などに、[ADPi Pro]からセンサへの電源供給が継続することがあります。電源供給を止めるには`adpictl set output 1 off`を実行してください。`adpictl`コマンドの詳細は[こちら](https://github.com/mechatrax/adpi-utils?tab=readme-ov-file#adpi-utils)を参照してください