Github Actionsを利用して呼び込み君を起動する

# はじめに

呼び込み君と時計を組み合わせたりミサイルスイッチと組み合わせたりしているうちに、`これ、IoTで使えるんじゃないか？`と混乱してきた。折角なので役に立つ呼び込み君にしたい。

![yobikomi_04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42445/31027a76-e813-da14-1bbb-fc5259cab9e6.png)
<font color='dark_pink'>呼び込み君ミニ</font>

## やりたい事をまとめる

IT屋の悩みと言えば、コンフリクト。コード書いてる最中に元ブランチと修正箇所がかぶってしまう奴。これを何とかしたい。

```mermaid
sequenceDiagram;

participant user1 as 開発者1
participant user2 as 開発者2
participant github as Github

user1 ->> github: create branch A from main
user2 ->> github: create branch B from main
user1 ->> github: push
note right of github: このタイミングで教えて欲しい
github ->> github: merge main
user2 ->> github: push
github ->> github: conflict
```

上記例では元ブランチが変わった事を開発者2に教えてあげたい。開発者2が受動的にmainブランチの更新を知る事で、あらかじめmainを取り込む事ができる。

```mermaid
sequenceDiagram;

participant user1 as 開発者1
participant user2 as 開発者2
participant github as Github
participant yobikomi as 呼び込み君

user1 ->> github: create branch A from main
user2 ->> github: create branch B from main
user1 ->> github: push
github ->> github: merge main
github ->> yobikomi: change main branch
yobikomi ->> user2: melody
user2 ->> github: get main branch
user2 ->> user2: local merge
user2 ->> github: push
github ->> github: merge main
```

Githubからプルリクエストのマージを検知して呼び込み君を鳴らす。こんな感じで通知を行う。

```mermaid
stateDiagram-v2

  state "Github" as s1
  state "Firebase Functions" as s2
  state "Notificate" as notif
  state "RabbitMQ" as s3
  state "Stream(Filter)" as s4
  state "呼び込み君" as d2

  [*] --> s1
  s1 --> s2
  s2 --> notif
  state notif {
    [*] --> s3
    s3 --> s4
    s4 --> s3
  }
  notif --> d2
```

Stream(Filter)はmainブランチの変更かどうかを判断する。

```mermaid
sequenceDiagram;

participant github as Github
participant firebase as Firebase
participant amqp as RabbitMQ Broker
participant stream as Filter
participant device2 as 呼び込み君


github ->> firebase: main merge
firebase ->> amqp: publish(Topic: github-actions-event)
amqp ->> stream: subscribe(Topic: github-actions-event)
stream ->> stream: filter
alt mainブランチの変更
  stream ->> amqp: publish(Topic: device-event)
end
amqp ->> device2: Subscribe(Topic: device-event)
device2 ->> device2: 音楽再生
```

# 結果

<iframe width="560" height="315" src="https://www.youtube.com/embed/TiaXS-q3tE0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

コードは[miyatama/github-actions-iot-flow1](https://github.com/miyatama/github-actions-iot-flow1)にまとめてアップしました。

# 準備

VMインスタンスなどを先に準備しておく

## Github

呼び込み君を起動させる起点となるプロジェクトを作成しておく。プロジェクトの構成や言語は何でも良い。プルリクエストが作成出来てマージできれば何でも良い。

![github_01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42445/c1a6e3cb-79e9-7a15-a801-74f93d5aa51e.png)
<font color="dark_pink">マジでどうでもいいって感じのプロジェクト名</font>

## Firebase

Github Actionsから呼び出されるプロジェクトを作成しておく。

[Firebase Console](https://console.firebase.google.com/?hl=ja)から新規プロジェクトを作成する。

プロジェクト名: github-event-receiver

![firebase_02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42445/dc6c3713-959c-045b-c3fc-a922f2732f83.png)

Functionsを利用するので、プロジェクトを作成したらアップグレード

![firebase_01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42445/dedd7ae6-7b5e-a4eb-4ef3-a1c2ffaef58f.png)

## RabbitMQ Broker

Firebaseからの通知受け取り、呼び込み君への通知を行う。

VMインスタンスの構成は次の通り

| column | value |
| :----- | :---- |
| name | github-actions-amqp-broker |
| region/zone | asia-northeast1/asia-northeast1-b |
| machine type | e2-standard-2 |
| boot disk | debian 10(buster) / 100[GB] |
| network service tag | amqp-broker-service |

インスタンスが作成出来たらファイウォールのルールを作成する。

RabbitMQ管理画面のルール

| column | value |
| :----- | :----- |
| name | rabbitmq-management |
| traffic direction | inbound |
| network service tag | amqp-broker-service |
| source filter | 0.0.0.0/0 |
| port | 15672/tcp |

RabbitMQのBrokerルール

| column | value |
| :----- | :----- |
| name | rabbitmq-broker |
| traffic direction | inbound |
| network service tag | amqp-broker-service |
| source filter | 0.0.0.0/0 |
| port | 5672/tcp |

RabbitMQのMQTTルール

| column | value |
| :----- | :----- |
| name | rabbitmq-mqtt-broker |
| traffic direction | inbound |
| network service tag | amqp-broker-service |
| source filter | 0.0.0.0/0 |
| port | 1883/tcp |

## 呼び込み君

下記を準備しておく。ミサイルスイッチがあるとかっこいいのでミサイルスイッチを使う。

+ 本体
  + ESP32 DevKit(ブレイクアウト済みのESP32基板)
  + 呼び込み君ミニ
  + 単三電池4本
  + 12[v]昇圧基板
  + ミサイルスイッチ
  + 5[v]降圧基板
  + リレー基板(5[v]駆動)
+ 開発環境
  + Windows
  + Arduino IDE

# 実装

## Github Actions

責務: mainブランチにマージされたらFirebaseに知らせる。

まずは[Github Actions](https://docs.github.com/ja/actions)を一通り読み込んで何が出来るかを確認する。ざっと読むと[pull_request](https://docs.github.com/ja/actions/using-workflows/events-that-trigger-workflows#pull_request)のcloseでマージを確認できそう。と言うかばっちりな記述が`Running your workflow when a pull request marges`って所に記載があったので、これを元に作成する。

監視対象のGithubリポジトリ内にworkflowのyamlを作成する。

```shell
cd /path/to/target/project
mkdir -p .github/workflows
touch .github/workflows/main_merge.yaml
```

```yaml:main_merge.yaml
on:
  pull_request:
    types:
      - closed

jobs:
  if_merged:
    if: github.event.pull_request.merged == true
    runs-on: ubuntu-latest
    steps:
    - run: |
        curl \
          -X POST \
          -H "Content-Type: application/json" \
          -d '{"event":"merge", "branch":"'${{github.base_ref}}'"}' \
          https://us-central1-github-event-receiver.cloudfunctions.net/githubActionsEvent  
```

適当にPull Requestを作ってマージしてみる。下記の様にActionの実行が確認できればOK。

![github_02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42445/2e5c6252-e41d-9730-a7c8-70fcf4d07586.png)

## Firebase

責務: Github Actionsから呼び出され、イベント情報をMQTT Brokerにpublishする。

後はいつもの通りプロジェクトを作成する

```shell
# functionsのみチェックを付ける
firebase init
```

ローカルでプロジェクトテンプレートが作成されたら、`amqp-ts`の参照を追加して`npm install`。

```json:package.json
  "dependencies": {
    "firebase-admin": "^10.0.2",
    "firebase-functions": "^3.18.0",
    "amqp-ts": "^1.8.0"
  },
```

後なぜかエラーがでるので、参照を追加する

```shell
npm i --save-dev @types/amqplib
```

コードはこんな感じ。呼ばれたらそのままamqpを叩く。

```ts:index.ts
import * as functions from "firebase-functions";
import * as Amqp from "amqp-ts";

export const githubActionsEvent =
  functions.https.onRequest((request, response) => {
    functions.logger.info("receive data: ", request.body);
    const user = "rabbitmq";
    const password = "rabbitmq";
    const rabbitMqHost = "RabbitMQ BrokerのIPアドレス";
    const rabbitMqEndpoint =
      "amqp://" + user + ":" + password + "@" + rabbitMqHost;
    const connection = new Amqp.Connection(rabbitMqEndpoint);
    const msg = new Amqp.Message(JSON.stringify(request.body));
    const exchange = connection.declareExchange(
        "amq.direct",
        "direct",
        {durable: true});
    connection.completeConfiguration().then(() => {
      exchange.send(msg, "github-actions-event");
      functions.logger.info("AMQP publish: ", msg);

      setTimeout(function() {
        connection.close();
      }, 500);
      response
          .status(200)
          .send("AMQP publish succeed");
    }).catch((err) => {
      functions.logger.error("AMQP connection error: ", err);
      response
          .status(500)
          .send("AMQP connection error");
    });
  });
```

## RabbitMQ Broker

責務: github-actions-event, device-eventのexchangeを提供する。

RabbitMQの設定は下記の通り

+ Exchange
  + amq.direct
    + ToQueue
      + route: github-actions-event
      + route: device-event
    + ToExchange
      + amq.topic
  + amq.topic(mqtt)
    + ToQueue
      + route: device-event

Dockerのセットアップを行う。

```shell
sudo apt update
sudo apt upgrade -y
sudo apt install -y \
  git
git clone https://github.com/miyatama/github-actions-iot-flow1.git
cd ./github-actions-iot-flow1/rabbitmq-broker/
./docker-setup.sh
```

Dockerがインストール出来たらコンテをビルドして実行

```shell
cd rabbitmq-service
find . -type f -name *.sh | xargs -n 1 -I % chmod +x %
sudo docker-compose build
sudo docker-compose up -d
```

コンテナは下記の通り。

| container | description |
| :---- | :----- |
| broker | exchangeを提供するブローカー |
| stream-processor | github-actions-eventを読み取ってdevice-eventへ流すフィルタリングを行う。 |
| github-actions-consumer | 挙動確認用。github-actions-eventトピックを購読してログに吐く。 |
| device-event-consumer |  挙動確認用。device-eventトピックを購読してログに吐く。 |
| dummy-producer | 挙動確認用。github-actions-eventトピックへ試験用のメッセージを送信する。 |
| mqtt-device-event-consumer | 挙動確認用。mqttのdevice-eventトピックを購読してログを吐く。 |

詳細は[rabbitmq-broker](https://github.com/miyatama/github-actions-iot-flow1/tree/main/rabbitmq-broker/rabbitmq-service)を参照。

キモはstream-processor。amq.directのgithub-actions-eventを購読してmainブランチへのマージをフィルタリングしてamq.directのdevice-eventへ呼び込み君起動メッセージを送る。

```elixir
defp consume(channel, tag, redelivered, payload) do
  :ok = Basic.ack channel, tag
  Logger.info("StreamApp.Consumer.consume() - #{payload}")
  Jason.decode!(payload, [{:keys, :atoms}])
  |> publish(channel)

rescue
  exception ->
    :ok = Basic.reject channel, tag, requeue: not redelivered
    IO.puts "Error: #{exception}"
end

defp publish(%{event: event, branch: branch}, channel) when event == "merge" and branch == "main" do
  Logger.info("publish(channel, map)")
  status = AMQP.Basic.publish(channel, "amq.direct", publish_topic(), "{\"event\": \"wakeup\", \"device\": \"yobikomi\"}")
  Logger.info("publish status: #{status}")
end

defp publish(data, channel) do
  Logger.info("not rise device event")
  Logger.info(data)
end
```

## 呼び込み君

責務: device-eventを監視し、イベント受領後に呼び込み君を鳴らす。

ESP32-WROOM-32を使って構築する。何となくの回路構成はこんな感じ。

```mermaid
stateDiagram-v2

state "電源(単三電池4本)" as power
state "昇圧回路" as upper
state "ミサイルスイッチ" as missile
state "変圧器版" as voltcontroller
state "ESP32-WROOM-32" as esp32
state "リレー基板" as relay
state "呼び込み君" as yobikomi

power --> upper: 6v
upper --> missile: 12v
missile --> voltcontroller
esp32 --> relay: signal
voltcontroller --> relay: 5v
relay --> yobikomi: melody
```
<font color="dark_pink">やる気が出たらちゃんと書きます</font>


### 回路

#### 昇圧基板

単三電池4本でミサイルスイッチ(v12)を動作させる為の12v昇圧基板。
ストロベリーリナックスで買った。

[LTC3111 昇降圧DC-DCコンバータモジュール(2.5V～15V出力)](https://strawberry-linux.com/catalog/items?code=13111&fbclid=IwAR1ITsnbiLEjskxidjZhODaejPaGAwGqDr30vuvxRRMBEabqNC6jbco9byI)

![yobikomi_06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42445/22816b76-da34-432c-2f29-55e852d0d61d.png)


#### 変圧器版

リレーが5v動作なので、[NJM7805FA](https://akizukidenshi.com/catalog/g/gI-08678/)で5vを作る。

![yobikomi_07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42445/0eb295c0-6572-43ea-3baf-4ca226507820.png)


#### リレー基板

リレーは[SS1A05D](https://akizukidenshi.com/catalog/g/gP-04328/)を利用。トランジスタ載ってるけど、多分要らない。

![yobikomi_08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42445/1945db7d-fc78-8d8c-0de1-1890110e2447.png)


リレー基板の入力は5vで、出力がNPNトランジスタに繋がる。出力のNPNトランジスタにベースを与えるのが、ESP32の信号で駆動するフォトカプラ(TLP621)。
なので、ESP32は変圧器版とは電気的に分離されており別電源で動作する。

#### 呼び込み君

赤丸の2箇所を接続すると呼び込み始めるので、配線を追加してリレー基板につなぐ。

![yobikomi_05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/42445/e0c1cb86-34a9-e974-acee-19a72a12002e.png)

### アプリ

[ESP32ではじめようIoT開発ハンズオン　実装用資料](https://qiita.com/shinfrom/items/2f350d789a89b7086df3)を参考にArduino IDEの環境を整える。

呼び込み君制御で利用するGPIOは2つ

+ IO15: 呼び込み君起動リレーへ接続
+ IO33: WiFi接続状態を表す

```c
#include <WiFi.h>
#include <PubSubClient.h>

const char ssid[] = "wifi-ssid";
const char pass[] = "wifi-password";
const char mqttBroker[] = "34.84.177.33";
const int mqttPort = 1883;
const char mqttUser[] = "mqtt-user";
const char mqttPassword[] = "mqtt-pwd";
const char topic[] = "device-event";

WiFiClient net;
PubSubClient client(net);

unsigned long lastMillis = 0;

void blinkBehavior() {
  digitalWrite(33, HIGH);
  delay(100);
  digitalWrite(33, LOW);
}

void connect() {
  blinkBehavior();
  Serial.print("checking wifi...");
  while (WiFi.status() != WL_CONNECTED) {
    blinkBehavior();
    Serial.print(".");
    delay(1000);
  }
  digitalWrite(33, HIGH);

  client.setServer(mqttBroker, mqttPort);
  Serial.print("\nconnecting...");
  while (!client.connected()) {
    String clientId = getMacAddr();
    if(client.connect(clientId.c_str(), mqttUser, mqttPassword)) {
      client.setKeepAlive(5);
      client.subscribe(topic);
      client.setCallback(subscribeTopic);
      break;
    } else {
      Serial.print("failed, rc=");
      Serial.print(client.state());
      Serial.println(" try again in 5 seconds");
      // Wait 5 seconds before retrying
      delay(5000);
    }
  }
  Serial.println("\nconnected!");
  client.loop();
}

void subscribeTopic(char* topic, byte* payload, unsigned int length) {
  Serial.print("receive topic: "); 
  Serial.println(topic);  
    
  digitalWrite(15, HIGH);
  delay(1000);
  digitalWrite(15, LOW);
}

String getMacAddr()
{
    byte mac[6];
    char buf[50];
    WiFi.macAddress(mac);
    sprintf(buf, "%02x%02x%02x%02x%02x%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
    return String(buf);
}

void setup() {
  Serial.begin(115200);
  WiFi.begin(ssid, pass);

  pinMode(15, OUTPUT);
  digitalWrite(15, LOW);

  pinMode(33, OUTPUT);
  digitalWrite(33, LOW);

  connect();
}

void loop() {
  delay(10);  // <- fixes some issues with WiFi stability

  if (!client.connected()) {
    connect();
  }
  client.loop();
}
```

# ふりかえり

GW中にやってしまおうと思っていたが、色々手を出している内に5月下旬になってしまった。
当初はAtomVMを利用してAMQPを動かそうとしてたけど、ビルド環境として使っていたRaspberryPi 3 Type B ver 1.2との相性が悪すぎるらしく断念。

+ AtomVMがビルドできない
+ AtomVMのビルドに成功してもバイナリが動かない
+ AtomVMのビルドに成功してバイナリが動いてもGPIOでエラー
+ etc

Docker使える環境ならとくに苦労なくできるんだろうなと思う。ビルドだけDockerでやってもよかったけど、その前にAtomVMへの熱が冷めてしまった。
直接ESP-IDF使おうとしたら`idf.py`の実行で既にエラー。なんだかよく分からない鬼門に入ったので、結局Windows + ArduinoIDEでやる事にした。

アプリが出来たら出来たで降圧基板からの3vでESP32 DevKitが動かないなど、最後までトラブルとの戦いだった。

ESP32 DevKitは意外と電流喰うんだなーと思った。
AtomVMには、いつかまたチャレンジしたい。