この記事は#NervesJP Advent Calendar 2020の19日目です。
18日目は @32hero さんの「Nerves+Phoenix 003 エムネチカ:分散型DB Mnesiaを使ってオリジナルCapeのLEDをエムネチカ」でした。
@kentaro さんからの パス(ウェブチカでElixir/Nervesに入門する(2020年12月版)) を受けて、kochi.exに所属する私がkochi.ex #4でお披露目させてもらった「NervesとPhonenix(Gigalixir)とGCP PubSubを使ってLチカ」した話を当日よりElixir成分多めで一昨日と今日の2回に分けて紹介します。
NervesだけでなくPhoenixやGCPの話が入ってくると1回でまとまらないことが多いんですよね。
ということで、2回に分けて投稿することにしました。
- NervesとPhonenix(Gigalixir)とGCP Cloud PubSubを使ってBBG CapeのLEDをチカした話〜Phoenix/GCPでPub編〜(1/2)
- NervesとPhonenix(Gigalixir)とGCP Cloud PubSubを使ってBBG CapeのLEDをチカした話〜NervesでSub編〜(2/2) ←いまここ
ちなみに明日は @piacerex さんの「WindowsからElixir IoT端末を作ってみた:「Raspberry Pi OS」を入れた後、Elixir IoTフレームワーク「Nerves」へ)」です。
概要(全体構成)
- Elixir/PhoenixのHeroku風PaaSサービスのGigalixirにウェブページを作成し、GCP Cloud Pub/SubにメッセージをPublish
- NervesをインストールしたBeagleBone Green(以下、BBG)でGCP Cloud Pub/Sub経由でSubscribeし、BBGに取り付けているCapeのLEDをON/OFF操作
kochi.exはRaspberry PiでなくBeagleBone Green推しなんです
BBGに取り付けるCapeは半分(以上?!)kochi.exに足を突っ込んでもらっている myasu さんの多大なるご協力のもと、koxhi.exで作成したものになります。他にもBBGとCapeがピッタリはまるケースも3Dプリンターで作っていたりするのですが、それは別の機会に…。
夏休みの #NervesJP 工作。まだ途中なので月末までに終わらせる。
— KIKUCHI Yutaka 🌗 菊池 豊 (@kikuyuta) August 17, 2020
My #Nerves craft work of summer vacation. Still manufacturing so I will finish it by the end of this month. pic.twitter.com/vnzWFheTCF
今回は下の絵の右半分のことを書きます。
Nervesの設定・実装
ここでようやくNervesが登場です。
NervesからPhoenixが起動するように設定して、PhoenixのLiveView画面でLEDの状態とSubされたテキストメッセージを確認できるようにします。
NervesからPhoenixを起動する
NervesからPhoenixを起動するための設定は以下のQiitaにあるので参考にしながらPoncho構成で設定します。
- NervesにPhoenixを入れてHTTPのGETでLEDをウェブチカ〜準備編1/3〜
- NervesにPhoenixを入れてHTTPのGETでLEDをウェブチカ〜Nerves設定編2/3〜
- NervesにPhoenixを入れてHTTPのGETでLEDをウェブチカ〜Phoenix設定・確認編3/3〜
GCPからSubする
以降は、Ponchoで構成したPhoenixのプロジェクトディレクトリで操作します。
Phoenix側のプロジェクトディレクトリは ledlive
としています。
❯ tree -L 1 led_live/
led_live/
├── exineris ←Nervesのプロジェクトディレクトリ
└── ledlive ←Phoenixのプロジェクトディレクトリ
依存関係の解消(mix deps.get)
mix.exs
に利用するパッケージを追記し、mix deps.get
コマンドを実行します。
defp deps() do
...
{:circuits_gpio, "~> 0.4"}, # GPIO操作ライブラリ
{:goth, "~> 1.1.0"}, # Google認証ライブラリ
{:google_api_pub_sub, "~> 0.28.1"}, # Google PubSubライブラリ
end
❯ mix deps.get
GCP鍵ファイル読み込み設定
config/config.exs
にGCPの鍵ファイルを読み込む設定を追記します。
見返してみると、どうして当時はlib
ディレクトリに置いてしま(略
config :goth,
json: "./lib/gcp/key/project-name-key.json" |> File.read!
LiveView用のスコープを設定(lib/ledlive_web/router.ex)
lib/ledlive_web/router.ex
にLiveView用のスコープを追記します。
ちなみに bbb
となっているのは、RaspberryPiではなくBeagleBone Greenを使っていて、 MIX_TARGET=bbb
としているからです(いつも使っている fish
だと環境変数の書き方違いますがー)。
scope "/", LedliveWeb do
pipe_through :browser
live "/", PageLive, :index
live "/bbb", BbbLive # ←この行を追記
end
Subするライブラリを作成(lib/ledlive/exineris_gcp_pubsub_subscripton.ex)
GCPのPubSubからSubscriptionするモジュールです。
利用したいモジュールでGoogleの プロジェクトID
と サブスクリプション名
を指定してスーパーバイザーを起動すると、handle_info/2
でSubscriptionされた値を取得できます。
{:ok, _task} = Exineris.GCP.PubSub.Subscription.start_supervisor(self(), :process_name, "プロジェクトID", "サブスクリプション名")
def handle_info({:process_name, subscription_message}, state) do
...
end
defmodule Exineris.GCP.PubSub.Subscription do
use Task
require Logger
@spec start_supervisor(pid, atom, String.t, String.t) :: {:ok, pid}
def start_supervisor(pid, atom_name, project_id, subscription_name) do
import Supervisor.Spec
children = [
supervisor(Exineris.GCP.PubSub.Subscription, [pid, atom_name, project_id, subscription_name])
]
opts = [strategy: :one_for_one, name: Exineris.GCP.PubSub.Subscription.Supervisor]
Supervisor.start_link(children, opts)
end
def start_link(pid, atom_name, project_id, subscription_name) do
task = Task.async(Exineris.GCP.PubSub.Subscription, :listen, [pid, atom_name, project_id, subscription_name])
{:ok, task.pid}
end
def listen(pid, atom_name, project_id, subscription_name) do
Logger.debug("#{__MODULE__} listen: #{inspect(atom_name)}, #{inspect(project_id)}, #{inspect(subscription_name)}")
# Authenticate
{:ok, token} = Goth.Token.for_scope("https://www.googleapis.com/auth/cloud-platform")
conn = GoogleApi.PubSub.V1.Connection.new(token.token)
# Make a subscription pull
{:ok, response} = GoogleApi.PubSub.V1.Api.Projects.pubsub_projects_subscriptions_pull(
conn,
project_id,
subscription_name,
[body: %GoogleApi.PubSub.V1.Model.PullRequest{
maxMessages: 10
}]
)
# Acknowledge the message was received & send message
if response.receivedMessages != nil do
Enum.each(response.receivedMessages, fn message ->
GoogleApi.PubSub.V1.Api.Projects.pubsub_projects_subscriptions_acknowledge(
conn,
project_id,
subscription_name,
[body: %GoogleApi.PubSub.V1.Model.AcknowledgeRequest{
ackIds: [message.ackId]
}]
)
send(pid, {atom_name, Base.decode64!(message.message.data)})
end)
end
listen(pid, atom_name, project_id, subscription_name)
end
end
GPIOを操作するライブラリを作成(lib/ledlive/gpioinout.ex)
GPIOの操作はkochi.exな @kikuyuta さんのはじめてNerves(8) 単一ホストで動くシステムを複数ホストに分散する / GPIO 操作の基本モジュールにあるgpioinout.ex
を使っています。
defmodule GpioInOut do
@behaviour GenServer
# use GenServer
require Circuits.GPIO
require Logger
def start_link(pname, gpio_no, in_out, ppid \\ []) do
Logger.debug("#{__MODULE__} start_link: #{inspect(pname)}, #{gpio_no}, #{in_out} #{inspect(ppid)}")
GenServer.start_link(__MODULE__, {gpio_no, in_out, ppid}, name: pname)
end
def write(pname, :true), do: GenServer.cast(pname, {:write, 1})
def write(pname, :false), do: GenServer.cast(pname, {:write, 0})
def write(pname, val), do: GenServer.cast(pname, {:write, val})
def read(pname), do: GenServer.call(pname, :read)
def stop(pname), do: GenServer.stop(pname)
@impl GenServer
def init({gpio_no, in_out = :input, ppid}) do
Logger.debug("#{__MODULE__} init_open: #{gpio_no}, #{in_out} ")
{:ok, gpioref} = Circuits.GPIO.open(gpio_no, in_out)
Circuits.GPIO.set_interrupts(gpioref, :both, receiver: ppid)
{:ok, gpioref}
end
@impl GenServer
def init({gpio_no, in_out = :output, _ppid}) do
Logger.debug("#{__MODULE__} init_open: #{gpio_no}, #{in_out} ")
Circuits.GPIO.open(gpio_no, in_out)
end
@impl GenServer
def handle_cast({:write, val}, gpioref) do
# Logger.debug("#{__MODULE__} :write #{val} ")
Circuits.GPIO.write(gpioref, val)
{:noreply, gpioref}
end
@impl GenServer
def handle_call(:read, _from, gpioref) do
{:reply, {:ok, Circuits.GPIO.read(gpioref)}, gpioref}
end
@impl GenServer
def handle_info(msg, gpioref) do
Logger.warn("#{__MODULE__} get_message: #{inspect(msg)}")
Circuits.GPIO.set_interrupts(gpioref, :both)
{:noreply, gpioref}
end
@impl GenServer
def terminate(reason, gpioref) do
Logger.debug("#{__MODULE__} terminate: #{inspect(reason)}")
Circuits.GPIO.close(gpioref)
reason
end
end
Sub確認用HTMLを作成(lib/ledlive_web/live/bbb_live.html.leex)
Subしたメッセージを確認するHTMLを作成します。
-
@sub_message
:Subscriptionされたメッセージを表示します -
@ai0
〜@ai3
:Capeから取得できるアナログインタフェースの情報を表示します(今回のPubSubに関係ありません) -
@x0
〜@x3
:Capeから取得できるデジタルインタフェース(入力)の情報を表示します(今回のPubSubに関係ありません) -
@y0_click
〜@y3_click
:Capeから取得できるLEDインタフェースの情報を表示します
<h2>LEDの操作と状態(bbb)</h2>
<h3 style="margin-top: 2em;">GCP Subscription Message</h3>
<div>
Subscription Message: <%= @sub_message %>
</div>
<h3 style="margin-top: 2em;">Analog Input</h3>
<div>
AI0: <%= @ai0 %><br />
AI1: <%= @ai1 %><br />
AI2: <%= @ai2 %><br />
AI3: <%= @ai3 %><br />
</div>
<h3 style="margin-top: 2em;">Input</h3>
<div>
X0: <%= @x0 %><br />
X1: <%= @x1 %><br />
X2: <%= @x2 %><br />
X3: <%= @x3 %><br />
</div>
<h3 style="margin-top: 2em;">Output</h3>
<div>
Y0:
<button phx-click="y0_click_on">on</button>
<button phx-click="y0_click_off">off</button>
<%= @y0_click %>
</div>
<div>
Y1:
<button phx-click="y1_click_on">on</button>
<button phx-click="y1_click_off">off</button>
<%= @y1_click %>
</div>
<div>
Y2:
<button phx-click="y2_click_on">on</button>
<button phx-click="y2_click_off">off</button>
<%= @y2_click %>
</div>
<div>
Y3:
<button phx-click="y3_click_on">on</button>
<button phx-click="y3_click_off">off</button>
<%= @y3_click %>
</div>
Subするプログラムを作成(lib/ledlive_web/live/bbb_live.ex)
GigalixirのPhoenixからPubされたメッセージを受け取ってLEDを操作する処理を書きます。
-
@project_id
GCPのプロジェクトIDを記載 -
@subscription_name
GCPのサブスクリプション名を記載
defmodule LedliveWeb.BbbLive do
use Phoenix.LiveView
require Logger
@analog_polling_msec 100
@ai_in_0 "/sys/bus/iio/devices/iio:device0/in_voltage0_raw"
@ai_in_1 "/sys/bus/iio/devices/iio:device0/in_voltage1_raw"
@ai_in_2 "/sys/bus/iio/devices/iio:device0/in_voltage2_raw"
@ai_in_3 "/sys/bus/iio/devices/iio:device0/in_voltage3_raw"
@pname :gcp_sub
@project_id "PROJECT_ID"
@subscription_name "bushukan"
# CapeのGPIO
@gpio_in_x0 5
@gpio_in_x1 48
@gpio_in_x2 31
@gpio_in_x3 30
@gpio_out_y0 60
@gpio_out_y1 50
@gpio_out_y2 51
@gpio_out_y3 4
def mount(_param, _session, socket) do
if connected?(socket) do
GpioInOut.start_link(:gpio_in_x0, @gpio_in_x0, :input, self())
GpioInOut.start_link(:gpio_in_x1, @gpio_in_x1, :input, self())
GpioInOut.start_link(:gpio_in_x2, @gpio_in_x2, :input, self())
GpioInOut.start_link(:gpio_in_x3, @gpio_in_x3, :input, self())
GpioInOut.start_link(:gpio_out_y0, @gpio_out_y0, :output)
GpioInOut.start_link(:gpio_out_y1, @gpio_out_y1, :output)
GpioInOut.start_link(:gpio_out_y2, @gpio_out_y2, :output)
GpioInOut.start_link(:gpio_out_y3, @gpio_out_y3, :output)
Process.send_after(self(), :analog_polling, @analog_polling_msec)
{:ok, _task} = Exineris.GCP.PubSub.Subscription.start_supervisor(self(), @pname, @project_id, @subscription_name)
end
socket = assign(
socket,
ai0: "",
ai1: "",
ai2: "",
ai3: "",
x0: "...",
x1: "...",
x2: "...",
x3: "...",
y0_click: "",
y1_click: "",
y2_click: "",
y3_click: "",
sub_message: ""
)
{:ok, socket}
end
### GCP Subした場合の処理
def handle_info({@pname, sub_message}, socket) when sub_message == "Y0:on" do
Logger.debug("#{__MODULE__} sub_message: #{inspect(sub_message)}")
GpioInOut.write(:gpio_out_y0, 1)
socket = assign(socket, y0_click: "on", sub_message: sub_message)
{:noreply, socket}
end
def handle_info({@pname, sub_message}, socket) when sub_message == "Y0:off" do
Logger.debug("#{__MODULE__} sub_message: #{inspect(sub_message)}")
GpioInOut.write(:gpio_out_y0, 0)
socket = assign(socket, y0_click: "off", sub_message: sub_message)
{:noreply, socket}
end
def handle_info({@pname, sub_message}, socket) when sub_message == "Y1:on" do
Logger.debug("#{__MODULE__} sub_message: #{inspect(sub_message)}")
GpioInOut.write(:gpio_out_y1, 1)
socket = assign(socket, y1_click: "on", sub_message: sub_message)
{:noreply, socket}
end
def handle_info({@pname, sub_message}, socket) when sub_message == "Y1:off" do
Logger.debug("#{__MODULE__} sub_message: #{inspect(sub_message)}")
GpioInOut.write(:gpio_out_y1, 0)
socket = assign(socket, y1_click: "off", sub_message: sub_message)
{:noreply, socket}
end
def handle_info({@pname, sub_message}, socket) when sub_message == "Y2:on" do
Logger.debug("#{__MODULE__} sub_message: #{inspect(sub_message)}")
GpioInOut.write(:gpio_out_y2, 1)
socket = assign(socket, y2_click: "on", sub_message: sub_message)
{:noreply, socket}
end
def handle_info({@pname, sub_message}, socket) when sub_message == "Y2:off" do
Logger.debug("#{__MODULE__} sub_message: #{inspect(sub_message)}")
GpioInOut.write(:gpio_out_y2, 0)
socket = assign(socket, y2_click: "off", sub_message: sub_message)
{:noreply, socket}
end
def handle_info({@pname, sub_message}, socket) when sub_message == "Y3:on" do
Logger.debug("#{__MODULE__} sub_message: #{inspect(sub_message)}")
GpioInOut.write(:gpio_out_y3, 1)
socket = assign(socket, y3_click: "on", sub_message: sub_message)
{:noreply, socket}
end
def handle_info({@pname, sub_message}, socket) when sub_message == "Y3:off" do
Logger.debug("#{__MODULE__} sub_message: #{inspect(sub_message)}")
GpioInOut.write(:gpio_out_y3, 0)
socket = assign(socket, y3_click: "off", sub_message: sub_message)
{:noreply, socket}
end
def handle_info({@pname, sub_message}, socket) do
Logger.debug("#{__MODULE__} sub_message: #{inspect(sub_message)}")
socket = assign(socket, sub_message: sub_message)
{:noreply, socket}
end
### Capeのアナログ値を取得・表示する処理
def handle_info(:analog_polling, socket) do
Process.send_after(self(), :analog_polling, @analog_polling_msec)
{:ok, ai0} = File.read(@ai_in_0)
{:ok, ai1} = File.read(@ai_in_1)
{:ok, ai2} = File.read(@ai_in_2)
{:ok, ai3} = File.read(@ai_in_3)
socket = assign(socket, ai0: ai0, ai1: ai1, ai2: ai2, ai3: ai3)
{:noreply, socket}
end
### Capeのデジタル値を取得・表示する処理
def handle_info({:circuits_gpio, @gpio_in_x0, _time, on_off}, socket) do
socket = assign(socket, x0: on_off)
{:noreply, socket}
end
def handle_info({:circuits_gpio, @gpio_in_x1, _time, on_off}, socket) do
socket = assign(socket, x1: on_off)
{:noreply, socket}
end
def handle_info({:circuits_gpio, @gpio_in_x2, _time, on_off}, socket) do
socket = assign(socket, x2: on_off)
{:noreply, socket}
end
def handle_info({:circuits_gpio, @gpio_in_x3, _time, on_off}, socket) do
socket = assign(socket, x3: on_off)
{:noreply, socket}
end
### Phoenixの画面からLEDを操作した場合の処理
def handle_event("y0_click_on", _, socket) do
GpioInOut.write(:gpio_out_y0, 1)
socket = assign(socket, y0_click: "on")
{:noreply, socket}
end
def handle_event("y0_click_off", _, socket) do
GpioInOut.write(:gpio_out_y0, 0)
socket = assign(socket, y0_click: "off")
{:noreply, socket}
end
def handle_event("y1_click_on", _, socket) do
GpioInOut.write(:gpio_out_y1, 1)
socket = assign(socket, y1_click: "on")
{:noreply, socket}
end
def handle_event("y1_click_off", _, socket) do
GpioInOut.write(:gpio_out_y1, 0)
socket = assign(socket, y1_click: "off")
{:noreply, socket}
end
def handle_event("y2_click_on", _, socket) do
GpioInOut.write(:gpio_out_y2, 1)
socket = assign(socket, y2_click: "on")
{:noreply, socket}
end
def handle_event("y2_click_off", _, socket) do
GpioInOut.write(:gpio_out_y2, 0)
socket = assign(socket, y2_click: "off")
{:noreply, socket}
end
def handle_event("y3_click_on", _, socket) do
GpioInOut.write(:gpio_out_y3, 1)
socket = assign(socket, y3_click: "on")
{:noreply, socket}
end
def handle_event("y3_click_off", _, socket) do
GpioInOut.write(:gpio_out_y3, 0)
socket = assign(socket, y3_click: "off")
{:noreply, socket}
end
end
確認
前回作成したGigalixirの画面から手元のNervesのLEDを操作することができました!
@piacerex さんのTwitterを拝借
kochi.ex最初のセッションは、GCP PubSub経由でWeb(LiveView製)←→LED付きデバイスのLチカ😜 #kochiex
— piacere@DigiDock (love Elixir&Gravity+仮想世界創造機構) (@piacere_ex) October 30, 2020
左のWeb画面のボタンをクリックすると、LED付きデバイスのLEDを付けたり、消したりできる😌
こりゃおもろい😙 pic.twitter.com/mG4QjORcJJ
明日は、 @piacerex さんの「WindowsからElixir IoT端末を作ってみた:「Raspberry Pi OS」を入れた後、Elixir IoTフレームワーク「Nerves」へ)」です。
そろそろNeosVRで遊んでみたくなってきてます〜。