Raspberry Piで制御系Web UIを開発した記録

国を挙げてのリモートワークが推進されている昨今、シリアルポートを使った機器の制御が再び脚光を浴びています（私の脳内で）。そのような機器にRaspberry Piを接続してWeb UIを実装した記録です。

![震災の傷が残るIT31LとRaspberry Pi3](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154669/b085544c-8799-b2e6-ee2b-9152728df3a8.jpeg) ![ボイスワープ当番自動設定システム](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154669/4a2a1e49-08df-11c2-c9e5-a6c5dcd77921.png)

# 準備

必要な機材

* 制御対象の機器
* Raspberry Pi
* USBシリアルケーブル（FTDI社の製品が定番でしょうか）

## 開発環境の準備

開発に必要なソフトをインストールします。以下のような組み合わせを想定。

* Web UIは[Sinatra](http://sinatrarb.com/)で実装
* DBバックエンドはsqlite3

```sh
# 文字化け回避
$ echo 'export LC_ALL=C' >> ~/.bashrc

# ソフトの更新
$ sudo apt-get update
$ sudo apt-get upgrade

# ツール類
$ sudo apt-get install vim
$ sudo apt-get install lv
$ sudo apt-get install minicom

# Rubyとbundler
$ sudo apt-get install ruby
$ sudo apt-get install ruby-dev
$ sudo gem install bundler

# sqlite3(gemのインストールに必要)
$ sudo apt-get install libsqlite3-dev
```

## シリアルの通信確認

カーネルがUSB-シリアルケーブルを認識していることを確認します。

```
$ dmesg | grep FTDI  
[    4.021957] usb 1-1.3: Manufacturer: FTDI
[    6.948121] usbserial: USB Serial support registered for FTDI USB Serial Device
[    6.948412] ftdi_sio 1-1.3:1.0: FTDI USB Serial Device converter detected
[    6.960133] usb 1-1.3: FTDI USB Serial Device converter now attached to ttyUSB0
```

最後の行で、シリアルケーブルが "/dev/ttyUSB0" として利用可能になっていることが分かるので、 minicom　コマンドを使って確認します。

```
$ minicom -D /dev/ttyUSB0
```
以下のような画面が表示されるので、通信できているか確認します。

```
Welcome to minicom 2.7.1

OPTIONS: I18n 
Compiled on Aug 13 2017, 15:25:34.
Port /dev/ttyUSB0, 12:00:07

Press CTRL-A Z for help on special keys

AT   （← キーボードから入力する）
OK   （← 正常な応答が返ってくるのを確認）
```

# アプリ開発

情報量、手間、自分の習熟度などからおおよそ以下のような方針で行くことにしました。

* DBはsqlite3（MySQLやPostgreSQLはさすがに重そう）
* DBの操作はActiveRecord（手慣れている）
* フロントエンドはBootstrap（他の情報が見つけられなかった）
* 定期処理はrakeタスク+wheneverの組み合わせ
* テンプレートエンジンはslim（なんとなく使ったことないものを試したかった）

## プロジェクトの準備

アプリケーション用のディレクトリ(例: 'app')を作成して移動

```
$ mkdir app
$ cd app
```

bundlerで初期化

```sh
$ bundle init
$ bundle config set path 'vendor/bundle'
```

Gemfileを修正

```
$ vim Gemfile
（以下を追加）
gem 'sinatra'
gem 'activerecord'
gem 'sinatra-activerecord'
gem 'serialport'
gem 'sqlite3'
gem 'sinatra-contrib', :require => false
gem 'rack-attack', :require => false
gem 'slim', :require => false
gem 'rake', :require => false
gem 'whenever', :require => false

group :development do
  gem 'sinatra-reloader'
end 
```

Gemのインストール

```sh
$ bundle install
```

サブディレクトリの作成（ここは各自の好みに合わせて）

```sh
# ActiveRecord
$ mkdir models

# helper
$ mkdir helpers

# 自前ライブラリとrakeのタスク
$ mkdir lib lib/tasks

# テンプレートエンジン用
$ mkdir views

# 静的ファイル用
$ mkdir public public/css public/js public/images
```

## その他の設定ファイル

requierやuseをどこに記述するのかは情報源でもバラバラで悩みましたが、基本的に全ての場面で必要なGemをアプリケーション本体でBundler.requireする(それ以外はGemfileに":require => false"をつける）ようにし、rackupで使う設定をconfig.ruに、rakeでのみ使うGemをRakefileでrequireするという方針で書き分けました。

### app.rb（の冒頭）

共通Gemのrequre、DBコネクタ、サブディレクトリのコードを読み込み。

```
require 'date'
require 'bundler'
Bundler.require 

set :database, {adapter: 'sqlite3', database: './db/app.sqlite3'}

['models', 'helpers', 'lib'].each do |_dir|
  Dir[File.dirname(__FILE__) + "/#{_dir}/*.rb"].each {|file| require file }
end

# 以下、プログラム本体
```

### config.ru

Web UIに必要なGemと各種設定を追加。念のためIPアドレスによるアクセス制限も。

```
require './app'
require 'slim'
require 'rack/attack'

# session
use Rack::Session::Pool, :expire_after => 86400

# CSRF etc.
require 'rack/protection'
use Rack::Protection

# IP access(default deny)
use Rack::Attack
Rack::Attack.safelist_ip("192.168.0.0/24")
Rack::Attack.blocklist_ip("0.0.0.0/0")

run Sinatra::Application
```

### Rakefile

タスク(./lib/tasks/*.rake)およびその実行に必要なGemを読み込む。

```
Dir.glob('lib/tasks/*.rake').each { |r| load r }
require 'sinatra/activerecord/rake'
require './app'
```
## DBの作成・マイグレーションとモデル

ここまでの設定で、DBのマイグレーション関連のタスクが実行できるようになっているはずなので、'bundle exec rake -T'コマンドで確認します。

```sh
$ bundle exec rake -T
rake db:create                # Creates the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (us...
rake db:create_migration      # Create a migration (parameters: NAME, VERSION)
rake db:drop                  # Drops the database from DATABASE_URL or config/database.yml for the current RAILS_ENV (use ...
 :
```

### DBの作成

app.rbに記述された設定を元にDBを作成します。

```
$ bundle exec rake db:create
```

### マイグレーション(テーブル)の作成

ActiveRecordの作法に則り、複数形でテーブルを作成します。

```
bundle exec rake db:create_migration NAME=create_dials
```

db/migrateの下に[タイムスタンプ]_create_dials.rbというファイルが生成されるので、編集します。

```ruby
$ vim db/migrate/XXXXXXXXXXXXXX_create_dials.rb
class CreateDials < ActiveRecord::Migration[6.0]
  def change
    create_table :dials do |t|
      t.string   :name
      t.string   :number, limit: 24
      t.timestamps
    end
  end
end
```

マイグレーションを実行します。

```
$ bundle exec rake db:migrate
```

このテーブルに対応するモデルを作成します。ActiveRecordなのでvalidationや関連付けなどもRailsと同様に書けます。

```ruby
$ vim models/dial.rb
class Dial < ActiveRecord::Base
end
```
## ページのレイアウト

views/layout.slimというファイルに基本のページレイアウトを記述します。執筆時点で最新の[BootstrapCDN](https://getbootstrap.com/docs/4.3/getting-started/introduction/)を使うための記述と、'@ msg' というハッシュの':success', ':info', ':warning', ':danger' をキーとする要素に値（リスト）が入っていると、アラームを表示するギミックなどを書いておきます。'== yield'の場所にここのslimファイルの内容が表示されます。

```slim
doctype 5
html lang="ja"
  head
    title Sample App.
    meta http-equiv="Content-Type" content="text/html" charset="utf-8"
    meta name="viewport" content="width=device-width, initial-scale=1"
    link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css" integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous"

  body
    .container
      h1 Sample App.

      - if @msg
        - [:success, :info, :warning, :danger].each do |tag|
          - if @msg[tag]
            div class="alert alert-#{tag.to_s}"
              ul
                - @msg[tag].each do |msg|
                  li = msg

      == yield

    hr

  script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"
  script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.7/umd/popper.min.js" integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1" crossorigin="anonymous"
  script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"
```

## シリアル通信の実装例

lib/serial.rb を作成して関連メソッドをこのファイルにまとめました。

具体的な処理内容は操作対象の機材のマニュアルに従って書いていくことにますが、ATコマンドを実行する処理とその戻り値のチェック処理は、例えばこんな感じになります。

```ruby
# 例: 'AT'コマンドを実行し、'OK'が返って来たかどうかチェックする
def exec_at_command
  check_result sp_comm('AT')
end

private

def check_result(buf, expect = 'OK')
  buf.each do |l|
    return true if l.match(/#{expect}/)
  end
  return false
end

def sp_comm(cmd)
  ret = []
  sp = SerialPort.new('/dev/ttyUSB0', 115200, 8, 1, 0)
  # 250ms でタイムアウト
  sp.read_timeout = 250

  sp.write("#{cmd}\r\n")
  # 機材からの応答を全て読み込む（タイムアウトで応答終了判定）
  while (l = sp.gets) != nil
    ret << l.chomp.strip
  end
  ret
end
```

## 定期処理

Rakefileに lib/tasks/*.rake を読み込む設定をしてあるので、lib/tasksの下にrakeファイルを置いてタスクを作成します。

```ruby
$ vim lib/tasks/cron.rake
namespace :cron do
  namespace :update do
    desc "Update something"
    task :something do
      # 処理を書く
    end
  end
end
```

作成したタスクはrake -Tで確認できます。

```
$ bundle exec rake -T
 :
cron:update:something   # Update something
 :
```

このタスクをwheneverを使って定期的に実行するようにします。まずは初期化。

```
$ bundle exec wheneverize
[add] creating `./config'
[add] writing `./config/schedule.rb'
[done] wheneverized!
```

作成された config/schedule.rbを編集します。例えば、毎日0時にcron:update:somethingタスクを実行する場合、以下のように書きます。ログファイルと環境変数の設定も合わせて追記しておきます。

```ruby
$ vim config/schedule.rb
set :output, 'log/cron.log'
set :environment_variable, 'RACK_ENV'
set :environment, 'deployment'

every 1.day, :at => '00:00 am' do
  rake "cron:update:something"
end
```

生成されるcrontabを確認します。

```
$ bundle exec whenever
0 0 * * * /bin/bash -l -c 'cd /home/pi/work/it31l_ui && RACK_ENV=deployment bundle exec rake cron:update:something --silent >> log/cron.log 2>&1'

## [message] Above is your schedule file converted to cron syntax; your crontab file was not updated.
## [message] Run `whenever --help' for more options.
```

問題なければcrontabを上書きします。

```
$ bundle exec whenever --update-crontab
[write] crontab file updated
```

# サービス化（自動起動）

アプリが完成したらサービス化して自動起動するようにします。開発したアプリは"/home/pi/work/app/"以下に設置してある想定です。

```
$ sudo vim /etc/systemd/system/app.service
[Unit]
Description=Sample app.
After=network.target

[Service]
Type=simple
User=pi
Group=pi
ExecStart=/home/pi/work/app/service.sh
Restart=no
WorkingDirectory=/home/pi/work/app

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

```
$ sudo vim /home/pi/work/app/service.sh
#!/bin/sh

cd $(dirname $(readlink -f "$0"))
bundle exec rackup -E production >> log/production.log 2>&1
```

パーミッションの設定

```sh
$ chmod +x service.sh
```

# 最後に

実装しているうちに、なんだかコントローラーがモノシリックなRailsみたいになっていきましたが、これはこれでアリなんじゃね？と思うことにしました(^^;