機械設備や装置の温度管理を自動化する「Modbusベース温度ロガー」
しまねソフト研究開発センターでは、IoT分野における先端技術支援や研究活動の一環として、mruby/cを使ったIoTデバイスの開発・製作を行っております。
このページでは、機械設備や装置の温度管理を自動化するニーズに対して、簡易にデータを計測・収集するためのModbusベース温度ロガーの開発を行いましたので、その取組事例をご紹介いたします。
目次
1.概要 |
2.内容 |
2-1.ハードウェア |
2-2.ソフトウェア |
2-3.稼働試験 |
2-4.試験結果 |
2-5.基盤回路図 |
2-6.ドキュメント |
2-7.サンプルコード |
2-8.注意事項 |
2-9.執筆者紹介 |
3.お問い合わせ先 |
概要
目的
- 主に製造業の温度管理が必要となる機械設備や装置の動作状況を自動記録(ロギング)することで、そのデータを活用・分析により装置の異常検知や故障予知、製造・加工条件の最適化、品質不良の波及範囲を特定するなどの品質管理の向上が図れます。
- しかしながら、既に導入している機械設備や装置には温度管理に必要となるデータログ機能が搭載していない、またはオプション機能で実現できるものの導入コストが高いといった課題があります。
- そこで、上記の課題を解決するため、より簡単かつ低コストで機械設備や装置の温度管理を自動化するModbusベース温度ロガーを開発しました。
こんな方にオススメ
- 既存設備に後付けで大幅な改造をしないで温度ロガーを設置したい方
- 温度ロガーの計測データを自社管理・運用するシステムに連携させたい方
- ラダー言語ではなく高級言語(Ruby)を用いたプログラミングに興味がある方
Modbusベース温度ロガーについて
- 今回、mruby/cを標準搭載するマイコンボード「RBoard」にRS485コンバータを接続し、Modbus経由で温度調節器の温度などのデータを測定して、一定時間ごとにWi-Fi経由でサーバへ送信するものです。
- なお、今回対象とする温度調節器は、オムロン制御機器の温度調節器(E5CC-QX2ASM-004)としています。通信方式は、通信プロトコルをPLC用のフィールド・ネットワークであるModbusを用いるとともに、物理層はRS485を用いてUART経由で送受信を行います。
- これにより、既に導入された機械設備や装置であっても、測定値をサーバ上でリアルタイムに確認したり、動作状況を自動記録(ロギング)することができます。
内容
今回、東裕人専門研究員の執筆によって、Modbusベース温度ロガーの開発におけるハードウェアおよびソフトウェア、稼働試験と結果、基板回路図に関するドキュメントを公開いたします。併せて、サンプルコードを公開しておりますので、予め注意事項をご確認の上、参照ください。
ハードウェア
ModbusはProgrammable Logic Controller(PLC)用のフィールドネットワークであり、物理層はRS485が使われることも多い。今回テスト対象としたオムロン製の温調器(E5CC-QX2ASM-004)でも RS485が採用されているため、RS485専用機として設計した。RS485とはUART経由で送受信するものとし、今回はGrove規格で接続が可能なモジュール SeeedStudio社製の103020193 (https://www.seeedstudio.com/Grove-RS485-p-2924.html) を採用した。
Wi-Fiモジュールは、Grove規格で接続が可能な製品の中から、入手性がよくスイッチサイエンスにて購入可能なCRESCENT-014を選定した(https://www.switch-science.com/catalog/5795/)。ただし、後述するとおり、開発時はケーブル一本で接続して非常に簡易に作業ができたが、基板の固定ができないため、最終成果物では半田付けとしている。
ソフトウェア
本品は、UARTを使いModbus(RS485)通信を行う。そのため、mruby/c標準のUARTクラスを使ってプログラミングが可能である。また、Modbusプロトコルもmruby/cを使って実装する事とし、ユーザープログラムとの分離を行わないことで、内部動作を明確にする。UARTに関して、RBoardは標準的にはUARTが1系統しかユーザープログラムに開放されていない。しかし、今回のシステムでは、UARTを要求するインターフェースが、RS485とWi-Fiモジュールの2系統必要となる。
一方、Grove Digital端子は今回のシステムでは使わないため、UART端子とDigital端子をプログラムで切り替えながら、一つのUARTハードウェアを共用することにした。
【データ取得部抜粋】
def modbus_read_var( sio, slave_address, read_address, count = 1)
# Modbus プロトコルに従い、リクエスト文字列 (+CRC) を作る
s = sprintf("%c\x03%c%c%c%c".b, slave_address, read_address >> 8, read_address & 0xff,
count >> 8, count & 0xff );
crc = crc16(s)
s << (crc & 0xff) << (crc >> 8)
# リクエストを送信
sio.write(s)
# 結果を受信
r1 = read_with_timeout(sio, 5)
(後略)
サーバーへのデータ送信は、http post プロトコルで REST-APIで受け付けるサーバーに対してデータ送信を行う事を想定した。ここで、t_current は現在の温度、t_preset は目標温度である。
【送信例】
URL
http://api.example.jp/datalogger.rb?ctrl=omron_tc&action=post"
POST DATA
{"type":"OMRON TC", "mac_address":"4C:55:CC:18:43:BD", "t_current":68.1, "t_preset":68}
稼働試験
以下の構成、条件のもとで、稼働試験を行った。
- 本機と温調器とは、長いケーブルの準備が間に合わなかったため、簡易的に1メートルのツイストペアケーブルで接続する。
- 温調器側には、120Ωの終端抵抗を端子台に共締めする。
- 温調器にソリッドステートリレー経由で負荷を接続する。今回は家庭にあるもので再現可能という制約から、負荷として電熱器、加熱媒体として水500cc、卵2個を選定した。
- 温度センサー(Pt100)を、負荷と十分な熱伝導性を確保した状態で接続する。今回は媒体が水なので、センサー感温部全体が確実に浸かるよう固定する。
試験結果
測定値が目標値に対して一旦オーバーシュートし、約30分程度で目標値に安定する様子が記録できた。約1時間稼働させた時のグラフを以下に示す。
基盤回路図
Wi-Fiモジュールの取付のため、Arduino用ユニバーサル基板を使って、Wi-Fiモジュールおよび、RS485モジュールの取付を行っている。以下に回路図を示す。
ドキュメント
このModbusベース温度ロガー開発に関するドキュメントは、以下のPDFファイルでご覧いただけます。
併せて、Modbusベース温度ロガー開発で用いたパーツリストをご覧いただけます。
「ドキュメント(Modbusベース温度ロガー開発について)」をダウンロードする(PDF:671kB)
「パーツリスト(Modbusベース温度ロガー開発について)」をダウンロードする(PDF:67kB)
サンプルコード
main.rb
# coding: utf-8
#
# Modbusを使ったオムロン製温調器との通信サンプルプログラム
#
# Copyright (C) 2022 Shimane IT Open-Innovation Center.
#
# This file is distributed under BSD 3-Clause License.
#
POST_URL = "http://example.jp/cgi-bin/datalogger.rb?ctrl=modbus&action=post"
SLEEP_TIME = 10 # 一回測定ごとにスリープする時間 (秒)
GPIO_WIFI_RESET = 2 # WiFi reset pin に繋いだ GPIO Pin 番号
##
# calculate CRC16
#
def crc16(data)
crc = 0xffff
poly = 0xa001
data.each_byte {|b|
crc ^= b
8.times {
lsb = crc & 0x01
crc >>= 1
crc ^= poly if lsb != 0
}
}
return crc
end
##
# read n bytes with timeout.
#
#@param [UART] sio uart object.
#@param [Integer] n_bytes read bytes
#@return [String] readed data.
#@return [Nil] timeout error.
#
# タイムアウトは1秒固定で、最低でもその時間を待つという意味。
#
def read_with_timeout( sio, n_bytes )
n_retry = 0
while true
r1 = sio.read(n_bytes)
return r1 if r1
return nil if (n_retry += 1) > 100
sleep_ms 10
end
end
##
# modbus read variable (register)
#
#@param [Uart] sio UART object.
#@param [Integer] slave_address modbus target address.
#@param [Integer] read_address target register address.
#@param [Integer] count num of read word.
#@return [Hash] return data. see below.
#
def modbus_read_var( sio, slave_address, read_address, count = 1)
s = sprintf("%c\x03%c%c%c%c".b, slave_address,
read_address >> 8, read_address & 0xff,
count >> 8, count & 0xff );
crc = crc16(s)
s << (crc & 0xff) << (crc >> 8)
# s.each_byte {|ch| printf("%02X ", ch) }; puts
sio.write(s)
r1 = read_with_timeout(sio, 5)
return {:status=>"NO RESPONSE ERROR"} if !r1
ret = {:status=>nil}
ret[:frame_type] = (r1.getbyte(1) & 0x80) == 0 ? "NORMAL" : "ERROR"
ret[:slave_address] = r1.getbyte(0)
ret[:function_code] = r1.getbyte(1) & 0x7f
if ret[:frame_type] == "NORMAL"
len = r1.getbyte(2)
r2 = read_with_timeout(sio, len)
if !r2
ret[:status] = "BROKEN FRAME ERROR"
return ret
end
r1 << r2
ret[:data] = r1[3, len]
ret[:crc] = r1.getbyte(len+4) << 8 | r1.getbyte(len+3)
ret[:status] = "OK"
else
len = 0
ret[:error_code] = r1.getbyte(2)
ret[:crc] = r1.getbyte(4) << 8 | r1.getbyte(3)
ret[:status] = "ERROR"
end
if crc16( r1[0, len+3] ) != ret[:crc]
ret[:status] = "CRC ERROR"
end
ret[:raw_data] = r1
return ret
end
##
# オムロン製温調器から、温度データを取得する。
#
def omron_tc_get_data()
$uart2.set_modem_params("baud"=>9600, "stop_bits"=>2, "txd"=>15, "rxd"=>16)
sleep_ms 100
$uart2.clear_rx_buffer()
data = modbus_read_var( $uart2, 1, 0x2000, 1 )
if data[:status] == "OK"
t_current = (data[:data].getbyte(0) << 8 | data[:data].getbyte(1)).to_f / 10
else
puts "Can't get current temp."
p data
end
sleep_ms 10 # Silent interval. at least 3.5 character.
data = modbus_read_var( $uart2, 1, 0x2103, 1 )
if data[:status] == "OK"
t_preset = (data[:data].getbyte(0) << 8 | data[:data].getbyte(1)).to_f / 10
else
puts "Can't get preset temp."
p data
end
# p [t_current, t_preset]
if t_current && t_preset
return {:t_current=>t_current, :t_preset=>t_preset}
end
return nil
end
#
# send data to server
#
def send_data( data )
$uart2.set_modem_params("baud"=>115200, "stop_bits"=>1, "txd"=>14, "rxd"=>13)
sleep_ms 100
$uart2.clear_rx_buffer()
if !$wifi.wait_for_connect()
wifi_hard_reset()
return nil
end
res = $wifi.http_post_json( POST_URL, data )
$wifi.reboot() if !res
return res
end
#
# WiFiモジュールのハードウェアリセット
#
def wifi_hard_reset()
$wifi_reset.write( 0 )
sleep 1
$wifi_reset.write( 1 )
end
#
# main
#
sleep 1
puts "START PROGRAM"
$uart1 = UART.new(1, 19200) # USBUART
$uart2 = UART.new(2, 115200) # WiFi / Modbus
# ready for Modbus pin first.
$uart2.set_modem_params("baud"=>9600, "stop_bits"=>2, "txd"=>15, "rxd"=>16)
sleep_ms 100
# ready for WiFi module.
$wifi_reset = GPIO.new( GPIO_WIFI_RESET )
$wifi_reset.setmode( 0 )
$wifi_reset.write( 1 )
$uart2.set_modem_params("baud"=>115200, "stop_bits"=>1, "txd"=>14, "rxd"=>13)
$wifi = AMW037.new( $uart2 )
#
# WiFi module setup
#
#$wifi.setup_module("SSID", "PASSWORD"); $wifi.reboot; sleep 1000
while !(mac_address = $wifi.mac_address())
puts "WiFi mac address error."
wifi_hard_reset()
end
puts "WiFi mac address is #{mac_address}"
send_count = 1
while true
res = omron_tc_get_data()
next if !res
printf("Temperature:%5.1f C Preset:%5.1f C\n",
res[:t_current], res[:t_preset] )
data = {:type => "OMRON TC",
:mac_address => mac_address,
:t_current => res[:t_current],
:t_preset => res[:t_preset],
:count => send_count }
if send_data( data )
send_count += 1
puts "Data send OK."
else
puts "ERROR: Data send failed."
end
sleep SLEEP_TIME
end
amw037.rb
# coding: utf-8
#
# Silicon Labs WiFi module AMW037 control class.
#
# Copyright (C) 2015-2022 Shimane IT Open-Innovation Center.
#
# This file is distributed under BSD 3-Clause License.
#
##
# debug print
#
def dp( s )
puts s
end
class Hash
def to_json_tiny
ret = "{"
flag_comma = false
self.each {|k,v|
ret << ", " if flag_comma
ret << %!"#{k}":#{v.inspect}!
flag_comma = true
}
ret << "}"
end
end
##
# AMW037 Class
#
class AMW037
attr_reader :is_read_timeout
##
# constructor
#
def initialize( node = nil )
@rfm = node || UART.new( 1 )
clear_buffer()
end
##
# clear tx/rx buffer
#
def clear_buffer()
@rfm.clear_tx_buffer()
@rfm.clear_rx_buffer()
@is_read_timeout = false
end
##
# send any command
#
def command( cmd )
dp ">>>SEND \"#{cmd}\""
@rfm.write("#{cmd}\r\n")
end
##
# get string with timeout
#
def get_string( timeout = 1000 )
@is_read_timeout = false
cnt = 0
timeout /= 10
while cnt < timeout
txt = @rfm.gets()
return txt if txt
cnt += 1
sleep_ms 10
end
@is_read_timeout = true
return nil
end
##
# chat
#
#@param [String] send message.
#@param [Integer] timeout timeout (ms)
#@return [String] result message
#@return [nil] seaquence error.
#@return [false] status code error.
#
def chat( send, timeout = 1000 )
if send
dp ">>>C:SEND \"#{send}\""
@rfm.write("#{send}\r\n")
end
# (see)
# https://docs.zentri.com/zentrios/wl/latest/serial-interface#response-format
@res_code = get_string( timeout )
dp "<<<C:RES #{@res_code.inspect}"
return nil if !@res_code || @res_code[0] != "R"
@res_code.chomp!
len = @res_code[2,5].to_i
@res_text = nil
cnt = 0
if len > 0
while (cnt += 1) < 100
@res_text = @rfm.read( len )
break if @res_text
sleep_ms 10
end
end
@is_read_timeout = (cnt == 100)
if @res_text
@res_text.chomp!
dp "<<<C:TEXT #{@res_text.inspect}"
else
@res_text = ""
end
return false if !@res_code.start_with?("R0")
return @res_text
end
##
# reboot
#
def reboot()
command("\r\nreboot")
sleep 5
clear_buffer()
end
##
# get MAC address
#
#@return [String] mac address
#@return [nil] retry error.
#
def mac_address()
mac = nil
retry_count = 10
while !mac
clear_buffer()
command("get wlan.mac")
while mac = get_string()
dp "<<<RECV #{mac.inspect}"
break if mac.size == 19
end
return nil if (retry_count -= 1) == 0
sleep 1
end
return mac.chomp
end
##
# setup module
#
def setup_module( ssid = nil, passkey = nil )
# factory reset.
mac = mac_address()
@rfm.write("factory_reset #{mac}\r\n")
puts ">>> SEND: factory_reset #{mac}"
sleep_ms 5000
puts "<<< RECV: " + @rfm.read_nonblock(1000).to_s
# to machine friendly mode.
# and set wlan parameter
cmds = <<-EOL.split("\n")
set setup.gpio.control_gpio -1
set system.print_level 0
set system.cmd.header_enabled 1
set system.cmd.prompt_enabled 0
set system.cmd.echo off
set wlan.hide_passkey 1
set wlan.auto_join.enabled 1
EOL
cmds << "set wlan.ssid #{ssid}\n" if ssid
cmds << "set wlan.passkey #{passkey}\n" if passkey
cmds << "save\n"
cmds.each {|cmd|
@rfm.write( cmd + "\r\n" )
puts ">>> SEND: " + cmd
sleep_ms 500
puts "<<< RECV: " + @rfm.read_nonblock(1000).to_s
}
end
##
# web setup mode
#
def setup_web()
return chat("setup web") == "In progress"
end
##
# start WiFi connection
#
def start_connect()
return if chat("network_up") == "In progress"
sleep_ms 100
clear_buffer()
end
##
# connected now?
#
def is_connected()
return chat("get wlan.network.status") == "2"
end
##
# wait for connect
#
#@return [Boolean] connect / disconnect
#
def wait_for_connect()
return true if is_connected()
retry_cnt = 0
while retry_cnt < 3
start_connect()
cnt = 0
while (cnt += 1) < 60
return true if is_connected()
sleep 1
end
retry_cnt += 1
end
return false
end
##
# HTTPサーバーへ、JSONデータをPOSTする
#
#@param [String] url URL (e.g. http://example.com/cgi-bin/sample.cgi)
#@param [Hash] data data hash.
#@return [Array<Integer,String>] returned status and contents.
#@return [Nil] error.
#
def http_post_json( url, data )
data_json = data.to_json_tiny()
handle = chat("http_post -o #{url} -l #{data_json.size} application/json", 10000)
return nil if !handle
command("stream_write #{handle} #{data_json.size}")
@rfm.write(data_json)
ret = nil
while !ret
break if !chat(nil)
status_code = chat("http_read_status #{handle}", 30000)
break if !status_code
status_code = status_code.to_i
contents = read_stream( handle, 1000 )
break if !contents
ret = [status_code, contents]
end
chat("stream_close #{handle}")
return ret
end
##
# Read data from stream
#
#@param [String,Integer] handle handle
#@param [Integer] max_len maximum length of return size.
#
def read_stream( handle, max_len = nil )
ret = ""
cnt = 0
while true
dp ""
res = chat("stream_poll #{handle} -r")
break if !res
status,size = res.split(",") # get status and data size
size = size.to_i
# break if no data status continue 5 seconds.
# because ZentriOS cannot detect closed HTTP stream
if status == "0"
break if (cnt += 1) > 5
sleep 1
next
end
cnt = 0
# read chunk data. max 1000 bytes.
# https://docs.zentri.com/zentrios/wl/1.5/cmd/commands#stream-read
while size > 0
size2 = size
size2 = 1000 if size2 > 1000
size -= size2
command("stream_read #{handle} #{size2}")
res = get_string() # "Rxxxxxx"
return nil if !res || !res.start_with?("R0")
# read size2 bytes.
cnt2 = 0
while size2 > 0
if data = @rfm.read_nonblock(size2)
dp ">>> Readed size #{data.size}"
ret << data if !max_len || ret.size < max_len
size2 -= data.size
cnt2 = 0
else
return nil if (cnt2 += 1) > 100
sleep_ms 10
end
end
get_string() # skip CRLF
end
break if status == "2" # break if connection has closed.
end
if max_len && ret.size > max_len
return ret[0, max_len]
end
return ret
end
end
注意事項
- 本事例の掲載情報の閲覧及び利用により、利用者自身、もしくは第三者が被った損害に対して、直接的、間接的を問わず、しまねソフト研究開発センターは責任を負いかねます。
- 本事例の内容を実践する中で用意された機器やパーツについてのご質問は、それぞれの機器やパーツの提供元にお問い合わせをお願いします。なお、機器やパーツの仕様は、本事例の公開当時のものです。
執筆者紹介
しまねソフト研究開発センター
東 裕人(Higashi Hirohito)/ 専門研究員
しまねソフト研究開発センターの専門研究員として、IoT分野で活用が期待できる小型デバイス向け開発言語「mruby/c」の研究開発、企業や大学・高専などとの共同研究、県内ITエンジニアの技術相談対応などの活動を行っています。詳しいプロフィールはこちら。
*このページで公開されている情報は2022年3月31日時点のものです。
お問い合わせ先
しまねソフト研究開発センター(担当:渡部)
Phone:0852-61-2225
Email:itoc@s-itoc.jp