35
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

《怖くないNerves》Wifiラジコン・カーをつくろう

Last updated at Posted at 2019-12-30

動機

巷で噂の並列関数型言語Elixirには、IOT/組み込み系向けにカスタマイズされた Nervesというソリューションがある。これを利用すると、ElixirとMakersの定番デバイスである Raspberry Piをコラボさせて、Myガジェットをつくることが出来るのである。

そして手元には、既にNervesの開発環境がインストールされたPCがあり、Raspberry Pi 3であれこれ遊んでいるのである。最初の一歩の"Lチカ"はとうの昔にやった。その発展形の"PWMでLチカ調光"も試してみた。そして、只今注目中のマイコンEPS32とWifiでお話もさせてみた。

さて、次は何をしよう?
やっぱり、そろそろ動くものが作りたいよね。ということで、Nervesでモーターをくるくる回してラジコン・カーを作ってみようと思う。まあ、何かを始めるときの動機なんてこんなものだ(汗)。世間のIOT熱に背を向けて、Nervesでフィジカル・コンピューティングをやってみるのだ。

構想設計

まずは、開発セオリーに則って、コンセプトを掲げよう。Nervesでモーターをくるくると回したいだけなので、品質Qはそこそこに、コストCは掛けず、製作日数Dは短い方が良い。そうだな、こんなのでどうだろう?

「ひよっ子エンジニアにも、お財布にも優しいNerves Car」

何かの選択に迷ったときは、このコンセプトに照らし合わせて判断すればよい。ということで、基本路線は安く手に入る市販品で開発を進めることになる。

では、構想設計を始めよう。
まず最初は、車体の検討だ。車体の構造としては、一般に下図のTYPE-A/TYPE-Bの2つが考えられる。TYPE-Aは駆動系1軸、操舵系1軸と、私たちの身の回りで走っている自動車と同じ構造だ。このタイプは、操舵回りの機構の設計/モノづくりが意外と難しいのだ。一方でTYPE-Bは駆動系2軸だけで、凝った機構もなく、とてもシンブルだ。ひよっ子のモノづくりでも不安がない。まあ反面、操舵の方法で妙技が必要になるが。ここはコンセプトに沿って迷わずTYPE-Bを採用しよう。
CAR-TYPE.png

車体の構造が決まったので、Nerves Carでキーとなる駆動系(モーター)の制御を深堀りする。今、TYPE-Bの車体が左にカーブしたとすると、左右の車輪は下図に示した弧を描いて動いたことになる。そう、旋回中心Oに対して、内側の左車輪が進んだ距離と外側の右車輪が進んだ距離の比が、丁度半径RLとRRの比に等しくなるのだ。つまり、左右のモーターの回転数比をその様に制御すれば、Nerves Carを左右にカーブさせることが出来るのだ。もちろん、真っ直ぐ進む場合は、左右のモーターの回転数比は1:1だ。理屈が分かればソフトウェア設計も難しくはない。めでたく操舵の方法が決まった。
navigation(4).svg.png

よし、次はモーターの選定とその制御回路だ。大概のメカトロ装置では、アクチュエータ(モーターなどの動力ユニット)が、装置の性能とコストの大きな部分を占めている。それ故に設計者の腕の見せ所ではあるのだが…… Nevers Carはとにかくお手軽をコンセプトとしているので、Makersにお馴染みの安価なギアBOX付きDCモーターを採用しよう。となると、モーター制御回路はほぼ一択で、Hブリッジ回路とPWM信号[*1]でモーター回転数を制御する構成が王道だ。
morter.png
Hブリッジ回路とは、概念的には下図左の様な回路である。図中のスイッチA群をONかつスイッチB群を0FFとするとモーターが順回転、逆にスイッチA群をOFFかつスイッチB群をONとするとモーターが逆回転する回路である。一方で、PWM信号とは下図右のようなパルス(方形波)で、一周期の間で信号が1(ON)となる時間と0(OFF)となる時間の比をプログラム出来る信号である。このPWM信号で先のHブリッジ回路のスイッチA群またはスイッチB群のON/OFFを制御すると、あら不思議[*2]モーターの回転数をコントロール出来るのである。
HブリッジPWM.png
Nerves Carは左右車輪でモーターを2軸持つので、Hブリッジ回路が2つ、PWM信号が4チャネル必要になる。一昔前まではHブリッジ回路は、専用のICやパワーFET/トランジスタで自作するしかなかったが、今や出来合い回路基板が自作するよりも安く手に入る時代となった。思いつきでちょこっとモノづくりをするには、良い時代になったものだなぁ。ワンコインで入手できるこの基板(Maker Drive)を採用しよう。
MakerDrive.png
https://www.switch-science.com/catalog/5522/

さて、Nerves Carのハード面の構想はこんなところでイイかな[*3]。では、ソフト面 - 操縦回りの検討に移ろう。時流に乗って、AIを搭載した自動運転と言いたいところだが、技術的にハードルが高いし、いろいろなセンサーを取り付ける必要もある。ひよっ子にも、お財布にも全く優しくないのだ。自動運転は将来に託して(資金面を含む ^^;)、もっとハードルが低い「ラジコン操縦」(無線)にしよう。

Raspberry Pi 3で利用できる無線は Wifiと Bluetoothがある。でも残念ながら現行の Nervesは Bluetoothを手軽には扱うことが出来ない様だ[*4]。よってWifi一択なのだが……通信プロトコルは http、rpc、socketで俺々プロトコルと選択の幅がある。さてどうするかな? Elixirには Erlang譲りのcoolな httpサーバーがあるので、これを利用しない手はないだろう。Webブラウザにリモコン画面を表示して、前進/後退速度、停止、左カーブ、直進、右カーブの指令を PUTメソッドに載せて Nerves Carに渡すことにしよう。この設計だと、PCをはじめスマホやタブレットもリモコンに出来て開発コスパがとても良い♡。
sequence(3)(1)-3.png
最後はリモコン画面のデザインだ。話は前後するが、当初は左右の車輪の回転をそれぞれ独立に操作するUIを考えていた。しかし、ちょこっと試してみると操作性が悪いのなんのって、Nerves Carを思うよう操縦することが出来ず、そこいらじゅうにぶつかる始末だった。そんな訳で、上で見たようにNerves Carの速度と方向をコントロールすることにしたのだ。まあ、それでも操作性は今ひとつなのだが(汗)。リモコン画面は下図のようなシンプルなもので良いだろう。
Screenshot_2019-12-15 RC Car Controller.png

メカ/ハード設計

構想が固まったので、具体的な設計に進もう。材料表は下表のようになる。単3電池はモーター駆動用、Li-ion 18650は Raspberry Pi 3の電源用だ。モーターはノイズ発生源なので、安全をみて電源を分けておいた。オシロスコープで実際のノイズの大きさを測定すれば、もう少し工夫が出来るかもしれないが、残念ながらそのような高額機材は持ち合わせていない(涙)

このNerves Carでは手元にあった木片を加工して車体フレームとしたが、改めてネットを漁って見ると、2組のモーター/車輪+車体ベース(アクリル板)のキットが千数百円で手に入るようだ[*5]。そういったキットを利用すれば、面倒な工作が省けるうえに見た目もカッコイイのでお薦めだ。

####材料表

数量 部品名称
2 ギアBOX付きDCモーター
2 プラスチックタイヤホイール
1 木片(車体ベース) 90x160x18
1 ボールキャスター Φ18
1 Maker Drive
1 Raspberry Pi 3
4 単3電池 6V
1 電池ケース
1 Li-ion 18650 3.7V
1 18650 Battery Charge Shield Board V3
* ビス/配線材ほか

メカニカルな構造は下写真の通り。プロの仕事であれば最初に組図を描くところだが、行きあたりばったりのレイアウト故に図面はない(汗)。三面図ならぬ三面写真で代用する。

車輪は3輪(うち一つはボールキャスター)とした。4輪にすると、4つの車輪が全て路面に接するように調整しなければならないので面倒なのだ。また、左右の車輪に均等に荷重が掛かるように、重量物のバッテリーを車体中心線に対して対象になるようにレイアウトしている。あとは見ての通り。
無題.png
回路図も実体配線図で代用だ。Raspberry Pi 3のGPIO-15,18,23,24とGNDを Maker Driveのピンヘッドに、モーターと電池BOXをターミナルに接続する。以上だ。
NervesCar_ブレッドボード3.png

#ソフト設計
やっと、Nervesの登場である。お待たせした…といっても薄々な内容で恐縮なのだが(汗)

Nervesの開発環境は既にインストールされているとする。まだならば、<[Nerves Installation](https://hexdocs.pm/nerves/installation.html#content)>を見ながらインストールしよう。

まず最初は、プロジェクトの作成だ。プロジェクト名は"wifi_car"で良いかな。下のように "mix nerves.new wifi_car"を実行すると、プロジェクト・ディレクトリ wifi_carが作成されると共に、Nervesの共通モジュール群が wifi_car/deps下に準備されるのだ。

$ mix nerves.new wifi_car
* creating wifi_car/config/config.exs
* creating wifi_car/config/target.exs
* creating wifi_car/lib/wifi_car.ex
* creating wifi_car/lib/wifi_car/application.ex
* creating wifi_car/test/test_helper.exs
* creating wifi_car/test/wifi_car_test.exs
* creating wifi_car/rel/vm.args.eex
* creating wifi_car/rootfs_overlay/etc/iex.exs
* creating wifi_car/.gitignore
* creating wifi_car/.formatter.exs
* creating wifi_car/mix.exs
* creating wifi_car/README.md

Fetch and install dependencies? [Yn] y
* running mix deps.get
Your Nerves project was created successfully.

You should now pick a target. See https://hexdocs.pm/nerves/targets.html#content
for supported targets. If your target is on the list, set `MIX_TARGET`
to its tag name:

For example, for the Raspberry Pi 3 you can either
  $ export MIX_TARGET=rpi3
Or prefix `mix` commands like the following:
  $ MIX_TARGET=rpi3 mix firmware

If you will be using a custom system, update the `mix.exs`
dependencies to point to desired system’s package.

Now download the dependencies and build a firmware archive:
  $ cd wifi_car
  $ mix deps.get
  $ mix firmware

If your target boots up using an SDCard (like the Raspberry Pi 3),
then insert an SDCard into a reader on your computer and run:
  $ mix firmware.burn

Plug the SDCard into the target and power it up. See target documentation
above for more information and other targets.

次は、Raspberry Pi 3向けのセットアップだ。環境変数 MIX_TARGETを設定した後に "mix deps.get"を実行する。Nerves systemやら toolchainやらが、こっそりとネットからダウンロードされ ~/.nerves/dl下にキャッシュされるのだ。

$ cd wifi_car/
$ export MIX_TARGET=rpi3
$ mix deps.get
Resolving Hex dependencies...
Dependency resolution completed:
Unchanged:

<中略>

All dependencies are up to date

Nerves environment
  MIX_TARGET:   rpi3
  MIX_ENV:      dev

==> nerves
Compiling 39 files (.ex)
Generated nerves app
==> wifi_car
Resolving Nerves artifacts...
  Resolving nerves_system_rpi3
  => Trying https://github.com/nerves-project/nerves_system_rpi3/releases/download/v1.10.0/nerves_system_rpi3-portable-1.10.0-F98813C.tar.gz
|==================================================| 100% (139 / 139) MB
  => Success
  Resolving nerves_toolchain_arm_unknown_linux_gnueabihf
  => Trying https://github.com/nerves-project/toolchains/releases/download/v1.2.0/nerves_toolchain_arm_unknown_linux_gnueabihf-linux_x86_64-1.2.0-EDC6266.tar.xz
|==================================================| 100% (47 / 47) MB
  => Success

そうだ、先に進む前に Wifiの設定を済ませ、テストをしておこう。Nervesでは、ターゲットのコンフィグを wifi_car/config/target.exsに記述することになっている。Wifiの設定は、このファイルの "config :nerves_init_gaget"行以降を下記のように書き換えればよい。ビルド時には WifiルータのIDとパスワードが必要なので、それぞれを環境変数 NERVES_NETWORK_SSIDと NERVES_NETWORK_PSKに設定してからビルドする。Wifiのテストは、下記の設定では nerves.local(mdns_domainの項)に対し pingを打てば良いだろう。

ここまでは、Nervesアプリ開発の定型的な手順だ。

use Mix.Config

<中略>

# Configure nerves_init_gadget.
# See https://hexdocs.pm/nerves_init_gadget/readme.html for more information.

# Setting the node_name will enable Erlang Distribution.
# Only enable this for prod if you understand the risks.
node_name = if Mix.env() != :prod, do: "wifi_car"

#config :nerves_init_gadget,
#  ifname: "usb0",
#  address_method: :dhcpd,
#  mdns_domain: "nerves.local",
#  node_name: node_name,
#  node_host: :mdns_domain

config :nerves_init_gadget,
ifname: "wlan0",
address_method: :dhcp,
mdns_domain: "nerves.local",
node_name: node_name,
node_host: :mdns_domain

# Configure wireless settings
key_mgmt = System.get_env("NERVES_NETWORK_KEY_MGMT") || "WPA-PSK"

ssid = System.get_env("NERVES_NETWORK_SSID")
psk  = System.get_env("NERVES_NETWORK_PSK")

config :nerves_network, :default,
  wlan0: [
    ssid: ssid,
    psk:  psk,
    key_mgmt: String.to_atom(key_mgmt)
  ]

<以下略>
$ NERVES_NETWORK_SSID=Buffalo-G-706A NERVES_NETWORK_PSK=********* mix firmware
$ mix burn
$ ping nerves.local
PING nerves.local (192.168.11.19) 56(84) bytes of data.
64 bytes from 192.168.11.19 (192.168.11.19): icmp_seq=3 ttl=64 time=182 ms
64 bytes from 192.168.11.19 (192.168.11.19): icmp_seq=4 ttl=64 time=8.13 ms
64 bytes from 192.168.11.19 (192.168.11.19): icmp_seq=5 ttl=64 time=4.80 ms
64 bytes from 192.168.11.19 (192.168.11.19): icmp_seq=6 ttl=64 time=5.70 ms

さあ、ここからが本題だ。Nerves Carアプリの具体設計を進めよう。構想設計でみたように、Nerves Carでは PWMポートが4チャネルと httpサーバーが必要だ。

Raspberry Pi 3には、独立にコントロール出来るハードウェアPWMポートが2対あるようだが、それではポート数が足りない。そんな訳でソフトウェアPWMポートを使用したいのだが、ラズパイ界では pigpioというライブラリの評判が良いようだ。幸いなことに、部分的ではあるが Nervesから pigpioを利用できるライブラリpigpioxがあった ー tokafishさん、ありがとう。pigioxを使用するには、Nerves Systemに pigpiodデーモンが組み込まれている必要があるのだが、ダウンロードした Nerves Systemのコンフィグ wifi_car/deps/nerves_system_rpi3/nerves_defconfigを確かめてみると "BR2_PACKAGE_PIGPIO=y"とあるので、そのままで利用0Kだ。

お次は、httpサーバーの選定だ。httpサーバーは、Nerves Carのリモコン画面の送信とリモコン司令の受信/ディスパッチを行うだけなので、当然DBは必要ない。リモコン画面はオペレーターの操作によって動的に変わるモノではない…いや、むしろちょこちょこ変わってしまうと、オペレーターが困るので静的な画面としよう。この条件に当てはるライブラリと言えば、plug_cowboy (プラス plug_static_index_html)かな。

という事で、wifi_car/mix.exsの依存ライブラリ・リストに"pigiox", "plub_cowboy", "plug_static_index_html"を付け加え、"mix deps.get"を実行してダウンロードしておく。

defmodule WifiCar.MixProject do
  use Mix.Project

  @app :wifi_car
  @version "0.1.0"

  <中略>

  # Run "mix help deps" to learn about dependencies.
  defp deps do
    [
      <中略>
      {:pigpiox, "~> 0.1"},
      {:plug_cowboy, "~> 2.1"},
      {:plug_static_index_html, "~> 1.0"},
    ]
  end

<以下略>

さて、お膳立てが整った。まずは本丸のモーター制御のモジュールから攻めることにしよう。モジュール名は"Vehicle"(wifi_car/lib/wifi_car/vehicle.ex)だ。構想設計でみたように、リモコンから届く司令は速度と方向だ。それぞれ、関数accelerator()とsteering()で受付け、左右の車輪のモータをコントロールしている。

accelerator()は、入力値として-100から100までの整数を受取る。これを関数drive_val()で Nerves Carの方向情報と組み合わせて-255〜255の制御値に変換し、関数wheel()に与えてモータードライバのPWM値としている。正の入力値は前進、負は後退、ゼロは停止である。

一方 steering()は、入力値として{"L","S","R"}を受取る。同じく関数drive_val()で Nerves Carの速度情報と組み合わせて-255〜255の制御値に変換し、関数wheel()に与えてモータードライバのPWM値としている。"L"は左カーブ、"S"は直進、"R"は右カーブである。

関数drive_val()では、先に触れたように Nerves Carの速度/方向情報からモータードライバに与える制御値を計算している。関数の返値は、左右のモーターを制御するGPIOポート番号とその制御値を組みとしたタプルのリストだ。制御値の計算には、停止、左カーブ、直進、右カーブで場合分けが必要だが、Elixir/Erlangが得意とする関数引数のパターンマッチによって場合分けを行っている。また、左カーブ、右カーブの制御値の計算では、構想設計で検討した左右モーターの回転数比を制御する仕組み以下のように組み込んでいる。

Nerves Carがカーブするときの車体中心の回転半径を20cmとし、モジュール属性@radiusで表す。また、車体の幅(約12cm)の1/2を@half_wで表す。

このとき、例えば左カーブするとすれば、構想設計でみた左車輪の回転半径RLと右車輪の回転半径RRは、それぞれ

  RL = @radius - @half_w
  RR = @radius + @half_w

となる。したがって、左右モーターの回転数比は、回転半径@radiusを基準にして、

  (左モータの回転数):(右モータの回転数)
    = (RL/@radius):(RR/@radius)
    = ((@radius - @half_w)/@radius):((@radius+@half_w)/@radius)

と計算することができる。

ここまでの実装をみて分かるように、今回の設計では Nerves Carをコントロールするために速度と方向を何処かに憶えておく必要がある。ふむ、無記憶で状態を持たないことを信条とする関数型言語を用いて、状態変数を実装しなければならないという事態は、一見するとダメダメなように見えるがそうではない。何かしらの有益な仕事をする組み込みシステム/情報システムは、必ずと言ってよいほど時間軸にそって変化する状態を内部に持っているのだ。関数型言語の掟は、関数の内部からそういった状態に直接アクセスするコードを排除し、状態が必要な場合は関数の引数として受け取るべしと言っているのだ…と思う(弱気)

ともあれ、Nerves Carでは状態変数が必要なのだが、これはもうElixirでは常套手段のAgentの出番だ。システムで記憶しておきたい状態を defstructで定義して、Agentに憶えさせておくのだ。そうそう、システム起動時にAgentを初期化するために、vehicle.exに "use Agent"の一行を加え、さらに wifi_car/lib/wifi_car/application.exの children(_target)のリストに"WifiCar.Vehicle"を追加しておこう。

defmodule WifiCar.Vehicle do
  use Agent

  # vehicle state
  #   accelerator: {-100..100} integer
  #   steering:    {"L", "S", "R"} Left/Straight/Right
  defstruct accelerator: 0, steering: "S"

  alias WifiCar.Vehicle
  require Logger

  # turning radius & half width of vehicle [cm]
  @radius 20
  @half_w  6

  # GPIO port for each wheel
  @left   {23, 24}
  @right  {15, 18}

  @doc """
  Initialize vehicle state
  """
  def start_link(_) do
    Agent.start_link(fn -> %Vehicle{} end, name: __MODULE__)
  end

  @doc """
  Command: accelerator.
  """
  def accelerator(speed) do
    prev = Agent.get(__MODULE__, &(&1))
    curr = %Vehicle{prev | accelerator: speed}
    Agent.update(__MODULE__, fn _ -> curr end)

    drive_val(curr)
    |> Enum.each(&wheel/1)
  end
  
  @doc """
  Command: steering.
  """
  def steering(dir) do
    prev = Agent.get(__MODULE__, &(&1))
    curr = %Vehicle{prev | steering: dir}
    Agent.update(__MODULE__, fn _ -> curr end)

    drive_val(curr)
    |> Enum.each(&wheel/1)
  end
  
  def state do
    Agent.get(__MODULE__, &(&1))
  end

  @doc """
  calculate moter control valus from vehicle state.
  Stop vehicle.
  """
  defp drive_val(%Vehicle{accelerator: 0}) do
    [
      {@left,  0},
      {@right, 0}
    ]
  end

  @doc """
  Turn left.
  """
  defp drive_val(%Vehicle{steering: "L", accelerator: speed}) do
    [
      {@left,  div(255*(@radius-@half_w)*speed, @radius*100)},
      {@right, div(255*(@radius+@half_w)*speed, @radius*100)}
    ]
  end

  @doc """
  Go straight.
  """
  defp drive_val(%Vehicle{steering: "S", accelerator: speed}) do
    [
      {@left,  div(255*speed, 100)},
      {@right, div(255*speed, 100)}
    ]
  end

  @doc """
  Turn right.
  """
  defp drive_val(%Vehicle{steering: "R", accelerator: speed}) do
    [
      {@left,  div(255*(@radius+@half_w)*speed, @radius*100)},
      {@right, div(255*(@radius-@half_w)*speed, @radius*100)}
    ]
  end

  @doc """
  Apply control value to the moter H-bridge
  """
  defp wheel({{port_a, port_b}, val}) do
    {a, b} = cond do
      val >  0 -> {val, 0}
      val == 0 -> {0, 0}
      val <  0 -> {0, -val}
    end
    Pigpiox.Pwm.gpio_pwm(port_a, a)
    Pigpiox.Pwm.gpio_pwm(port_b, b)

    Logger.info("PORT(#{port_a}) <- #{a}")
    Logger.info("PORT(#{port_b}) <- #{b}")
  end
end
defmodule WifiCar.Application do
  # See https://hexdocs.pm/elixir/Application.html
  # for more information on OTP Applications

 <中略>

  def children(_target) do
    [
      WifiCar.Vehicle
    ]
  end

<以下略>

ふぅ、ここらでちょっと休憩(^^;)
ここまでのコードを "mix firmware"でビルドし、Raspberry Pi 3にインストールして、ちょこっと Nerves Carを動かしてみよう。下のように sshで Raspberry Pi 3に接続し、iexのコマンド・プロンプトから "WifiCar.Vehicle.accelerator(30)"等を叩けば、Nerves Carが走る筈だ。思うように走らない場合は、回路の配線やアプリのコードを見直して Bug取りをしておくべし。

$ ssh nerves.local
nteractive Elixir (1.9.1) - press Ctrl+C to exit (type h() ENTER for help)
Toolshed imported. Run h(Toolshed) for more info
RingLogger is collecting log messages from Elixir and Linux. To see the
messages, either attach the current IEx session to the logger:

  RingLogger.attach

or print the next messages in the log:

  RingLogger.next

iex(wifi_car@nerves.local)1> WifiCar.Vehicle.accelerator(30)
:ok
iex(wifi_car@nerves.local)1> WifiCar.Vehicle.steering("L")
:ok
iex(wifi_car@nerves.local)8> WifiCar.Vehicle.state
%WifiCar.Vehicle{accelerator: 30, steering: "L"}
iex(wifi_car@nerves.local)1> WifiCar.Vehicle.steering("S")
:ok
iex(wifi_car@nerves.local)1> WifiCar.Vehicle.accelerator(-30)
:ok
iex(wifi_car@nerves.local)1> WifiCar.Vehicle.accelerator(0)
:ok

無事 Nerves Carが走ったので、リモコン用 httpサーバーの実装に取り掛かろう。モジュール名は"Controller"(wifi_car/lib/wifi_car/controller.ex)だ。

実装といっても、受け取った PUTメソッドのリクエストを、Plug.Routerでディスパッチして Vehicleモジュールの所定の関数を呼び出しているだけだ。Elixir SchoolのPlugチュートリアルよりも薄っぺらなコードだ(汗)。ええっと、敢えて触れておくべきところは…(1)PUTメソッドの引数を受け取るための "plug Plug.Parsers, parsers: [:urlencoded]"と、(2)ブラウザからの要求に対しリモコン画面を返すための "plug Plug.Staticほにゃらら"が必要なことぐらいかな。

PowerONと共に httpサーバーを起動したいので、wifi_car/lib/wifi_car/application.exの start(_type, _args)の中の childrenリストに WifiCar.Controllerを追加しておく。また、Plug.Staticの from:オプションに :wifi_car(アプリ名)を指定したので、リモコン画面の静的assetは wifi_car/priv/static下に置くことになる。

defmodule WifiCar.Controller do
  use Plug.Router
  
  alias WifiCar.Vehicle

  plug Plug.Static.IndexHtml, at: "/"
  plug Plug.Static, at: "/", from: :wifi_car

  plug :match
  plug Plug.Parsers, parsers: [:urlencoded]
  plug :dispatch

  put "/accelerator" do
    speed = String.to_integer(conn.params["value"])
    Vehicle.accelerator(speed)
    send_resp(conn, 204, "OK")
  end

  put "/steering" do
    dir = conn.params["value"]
    Vehicle.steering(dir)
    send_resp(conn, 204, "OK")
  end
  
  match _ do
    IO.inspect(conn)
    send_resp(conn, 404, conn.request_path)
  end
end
defmodule WifiCar.Application do

  <中略>

  def start(_type, _args) do
    # See https://hexdocs.pm/elixir/Supervisor.html
    # for other strategies and supported options
    opts = [strategy: :one_for_one, name: WifiCar.Supervisor]
    children =
      [
          {Plug.Cowboy, scheme: :http, plug: WifiCar.Controller, options: [port: 8080]}
      ] ++ children(target())

    Supervisor.start_link(children, opts)
  end

<以下略>

#UI設計
よし、これが最後だ。リモコン画面の具体設計だ。
リモコン画面のイメージは構想設計で見たとおりだ。Nerves Carの速度と方向をそれぞれ独立に操作するWidgetsを画面に配置する。速度の指定には、スライダー・コントロールを用いて、前進速度3段階、後退速度3段階、停止を選択できるようにしよう。jQueryUIのスライダー・コントロールの機能強化版"Slider Pips"を使えば簡単に実現できそうだ ー simeydotmeさん、ありがとう。一方で、方向の指定はラジオ・ボタンを用いて、左カーブ、直進、右カーブを選択出来るようにする。

Nerves Carとの通信の仕組みは、次のとおりだ。オペレーターがスライダー・コントロールまたはラジオ・ボタンを操作すると、その chengeイベントに対応するJavascriptのコードが呼び出され、ajaxで司令(PUTメソッド)が Nerves Carに送られるようになっている。尚、通信エラー等の異常系の処理は一切実装していないので、トラブルが起こるとPowerOffするしか手段がない(汗) まあ、フロントエンド&Javascriptは初めての経験なので、仕方あるまい。

ソフト設計で見たように、htmlやjavascriptのファイルは wifi_car/priv/static下に置く。Nervesのビルドツールは、priv下のファイルをターゲット・システムに同梱してくれるようだ。何か補助的なファイルをターゲット・システムに追加したい場合は、priv下に置くのが常套手段なのかな。

<!doctype html>
<html lang="jp">
<head>
  <meta charset="utf-8">
  <title>Wifi Car Controller</title>
  <script src="//code.jquery.com/jquery-1.12.4.js"></script>

  <link rel="stylesheet" href="//code.jquery.com/ui/1.12.1/themes/smoothness/jquery-ui.css">
  <script src="//code.jquery.com/ui/1.12.1/jquery-ui.js"></script>

  <link rel="stylesheet" type="text/css" href="assets/jquery-ui-slider-pips.min.css">
  <script type="text/javascript" src="assets/jquery-ui-slider-pips.min.js"></script>

  <style>
    * {
      box-sizing: border-box;
      font-family: 'Helvetica Neue', sans-serif;
    }
        
    body {
      background-color: black;
    }
        
    #controller {
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
    }

    #accelerator {
      border: 4px solid firebrick;
      border-radius: 5px;
      margin-right: 3rem;
    }
    #accelerator-slider {
      height: 16rem;
      margin: 1rem 4rem;
      background: gray;
      border-color: gray;
    }
    #accelerator-slider .ui-slider-handle {
      background: darkred;
      border-color: gray;
      width: 1.6rem;
      left: -0.4rem;
    }

    #steering {
      display: flex;
      flex-wrap: wrap;
      justify-content: center;
      align-items: center;
    }
    #steering .input-container {
      position: relative;
      height: 7rem;
      width:  7rem;
      margin: 0.5rem;
    }
    #steering .input-container .radio-button {
      opacity: 0;
      position: absolute;
      top: 0;
      left: 0;
      height: 100%;
      width:  100%;
      margin: 0;
      cursor: pointer;
    }

    #steering .input-container .radio-icon {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      height: 100%;
      width:  100%;
      border: 4px solid #079ad9;
      border-radius: 5px;
      /*transition: transform 300ms ease;*/
    }
    #steering .input-container .radio-icon svg {
      fill: #079ad9;
      height: 60%;
      width:  60%;
    }

    #steering .input-container .radio-button:checked + .radio-icon {
      background-color: #079ad9;
      border: 2px solid #079ad9;
      color: white;
      transform: scale(1.1, 1.1);
    }
    #steering .input-container .radio-button:checked + .radio-icon  svg {
      fill: white;
      background-color: #079ad9;
    }
  </style>
</head>

<body>
<div id="controller">
  <div id="accelerator">
    <div id="accelerator-slider"></div>
  </div>
  
  <div id="steering">
    <div class="input-container">
      <input id="left" class="radio-button" type="radio" name="steering" value="L" />
      <div class="radio-icon">
        <svg viewBox="0 0 48 48" hxmlns="http://www.w3.org/2000/svg">
          <path d="M 5.2126809,20.194307 38.211333,0.68572534 c 2.68114,-1.58431502 6.787225,-0.0468967 6.787225,3.87171486 V 43.565233 c 0,3.515482 -3.815465,5.634141 -6.787225,3.871714 L 5.2126809,27.937736 c -2.943631,-1.734297 -2.95301,-6.009123 0,-7.743429 z" />
        </svg>
      </div>
    </div>

    <div class="input-container">
      <input id="straight" class="radio-button" type="radio" name="steering" value="S" />
      <div class="radio-icon">
        <svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
          <path d="M 42.453333,5.4266628 C 41.92,3.853329 40.426667,2.73333 38.666667,2.73333 H 9.3333329 c -1.7599996,0 -3.2266662,1.119999 -3.7866662,2.6933328 L 0,21.399995 V 42.733333 C 0,44.2 1.2,45.4 2.666667,45.4 H 5.3333333 C 6.8,45.4 8,44.2 8,42.733333 v -2.666666 h 32 v 2.666666 C 40,44.2 41.2,45.4 42.666667,45.4 h 2.666666 C 46.8,45.4 48,44.2 48,42.733333 V 21.399995 Z M 9.3333329,32.066667 c -2.2133329,0 -3.9999996,-1.786667 -3.9999996,-4 0,-2.213338 1.7866667,-4.000006 3.9999996,-4.000006 2.2133341,0 4.0000001,1.786668 4.0000001,4.000006 0,2.213333 -1.786666,4 -4.0000001,4 z m 29.3333341,0 c -2.213334,0 -4,-1.786667 -4,-4 0,-2.213338 1.786666,-4.000006 4,-4.000006 2.213333,0 4,1.786668 4,4.000006 0,2.213333 -1.786667,4 -4,4 z M 5.3333333,18.733328 9.3333329,6.7333295 H 38.666667 l 4,11.9999985 z" />
        </svg>
      </div>
    </div>

    <div class="input-container">
      <input id="right" class="radio-button" type="radio" name="steering" value="R" />
      <div class="radio-icon">
        <svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
          <path d="M 42.787316,20.194307 9.7886641,0.68572534 C 7.1075241,-0.89858968 3.0014391,0.63882864 3.0014391,4.5574402 V 43.565233 c 0,3.515482 3.815465,5.634141 6.787225,3.871714 L 42.787316,27.937736 c 2.943631,-1.734297 2.95301,-6.009123 0,-7.743429 z" />
        </svg>
      </div>
    </div>
  </div>
</div>
 
<script>
// accelerator action
$('#accelerator-slider').slider({
  orientation: 'vertical',
  min: 0,
  max: 6,
  value: 3,
}).slider('pips', {
  rest:  'label',
  labels: ['-3', '-2', '-1', '0', '+1', '+2', '+3'],
});

$('#accelerator-slider').on('slidechange', function(event, ui) {
  console.log(ui.value);
  $.ajax({
    url:  './accelerator',
    type: 'PUT',
    data: {
      'value': [-100, -66, -33, 0, 33, 66, 100][ui.value]
    },
    dataType: 'text',
  }).always(function(arg1, status, arg2) {
    // 通信完了時の処理
  });
});

// steering action
$('#straight').prop('checked', true);

$('.radio-button').change(function() {
  var selected = $(this).val();
  console.log(selected);
  $.ajax({
    url:  './steering',
    type: 'PUT',
    data: {
      'value': selected
    },
    dataType: 'text',
  }).always(function(arg1, status, arg2) {
      	  // 通信完了時の処理
  });
});
</script>
</body>
</html>

#発展
ああ、終わった。お疲れ様。
一応、曲がりなりにも Nervesで Wifiラジコン・カーが作れた。スマホのブラウザにリモコン画面を表示し Nerves Carを操縦すると、それなりに面白い。だが、その傍らで物足りない思いがむくむくと湧いてくるだろう。まあ、"おもちゃ"とはそういう運命なのだ。それは、Nerves Carを進化させるときなのだ。

いくつか進化のアイデアを綴っておこう。

  • Bluetooth LEによる通信を実装し、micro:bitのジャイロセンサで操縦できるようにする
  • 超音波センサ等を搭載し、最近の自動車の様に衝突回避を行う機能を実装する
  • タートル幾何学のコマンド列を解釈し、プログラム走行が出来るようにする
  • カメラとAIを搭載し、環境を認識しながらの自動運転が出来るようにする

などなど

#参考文献

  1. Nerves Project
  2. pigpiox
  3. pigpio library
  4. Slider Pips
  5. 移動するメカ・ロボットと制御の基礎

##注釈
[*1]Pulse Width Modulation - 任意のアナログ波をパルス幅の異なるデジタル波の列で近似する変調方法。本稿で"PWM信号"と呼んでいるモノは、パルスのデューティー比を単純に変えるだけのものなので、本格的なPWMではない。

[*2]言葉のあや(^^;) モーターに供給する電力を制御しているので、モーターの回転数がコントロール出来るのは不思議でも何でもない。

[*3]製品開発では、電源周りの設計に結構神経を使うのだが、Nerves Carでは電池を使って簡単に済ませる。

[*4]そもそも、標準のNerves Systemには Bluetoothドライバが組み込まれていない。直接低レベルなHCIを叩いてBluetoothの機能を提供しようとするライブラリがあるにはあるが…

[*5]実は、これ↓を手配していたのだが、待てど暮せど届く気配がなく、しびれを切らして販売元に問い合わせたら「chinaからの発送で、どうやら郵便事故が起きたようです」との回答だった。結局、諦めて返金して貰ったのだ。
robot_kit.png

35
22
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?