国を挙げてのリモートワークが推進されている昨今、シリアルポートを使った機器の制御が再び脚光を浴びています(私の脳内で)。そのような機器にRaspberry Piを接続してWeb UIを実装した記録です。
準備
必要な機材
- 制御対象の機器
- Raspberry Pi
- USBシリアルケーブル(FTDI社の製品が定番でしょうか)
開発環境の準備
開発に必要なソフトをインストールします。以下のような組み合わせを想定。
- Web UIはSinatraで実装
- DBバックエンドはsqlite3
# 文字化け回避
$ 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で初期化
$ 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のインストール
$ bundle install
サブディレクトリの作成(ここは各自の好みに合わせて)
# 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'コマンドで確認します。
$ 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というファイルが生成されるので、編集します。
$ 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と同様に書けます。
$ vim models/dial.rb
class Dial < ActiveRecord::Base
end
ページのレイアウト
views/layout.slimというファイルに基本のページレイアウトを記述します。執筆時点で最新のBootstrapCDNを使うための記述と、'@ msg' というハッシュの':success', ':info', ':warning', ':danger' をキーとする要素に値(リスト)が入っていると、アラームを表示するギミックなどを書いておきます。'== yield'の場所にここの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コマンドを実行する処理とその戻り値のチェック処理は、例えばこんな感じになります。
# 例: '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ファイルを置いてタスクを作成します。
$ 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タスクを実行する場合、以下のように書きます。ログファイルと環境変数の設定も合わせて追記しておきます。
$ 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
パーミッションの設定
$ chmod +x service.sh
最後に
実装しているうちに、なんだかコントローラーがモノシリックなRailsみたいになっていきましたが、これはこれでアリなんじゃね?と思うことにしました(^^;