しまねソフト研究開発センターでは、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ベース温度ロガー開発で用いたパーツリストをご覧いただけます。

pdfファイル「ドキュメント(Modbusベース温度ロガー開発について)」をダウンロードする(PDF:671kB)

pdfファイル「パーツリスト(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