Modbus RTU/TCP Slave

LogicMachine as Modbus RTU/TCP Slave #

Introduction #

This script supports multiple slave IDs and any number of coils/registers.

Supported data types :

  • dt.bool (1)
  • dt.bit4 (1)
  • dt.int8 (1)
  • dt.uint8 (1)
  • dt.int16 (1)
  • dt.uint16 (1)
  • dt.float16 (1)
  • dt.int32 (2)
  • dt.uint32 (2)
  • dt.float32 (2)
  • dt.int64 (4)

The number in the brackets represents how many registers will be used per each object. It means if int64 is used make sure to leave a gap for 4 registers in the mapping table.

Only 2023 firmware and newer is supported

User library #

Create a user library named mbslave (Scripting > User libraries) and paste this code:

local _M = {}
local byteswap, wordswap
local f16mult = 1
local mapping = {}
local exception = {}
local reply = {}

local fncodes = {
  [1] = 'readcoils',
  [2] = 'readdiscreteinputs',
  [3] = 'readregisters',
  [4] = 'readinputregisters',
  [5] = 'writecoil',
  [6] = 'writeregister',
  [15] = 'writecoils',
  [16] = 'writeregisters',
}

local mapfncodes = {
  [1] = 'coils',
  [2] = 'discreteinputs',
  [3] = 'registers',
  [4] = 'inputregisters',
  [5] = 'coils',
  [6] = 'registers',
  [15] = 'coils',
  [16] = 'registers',
}

local dtlens = {
  [ dt.bool ] = 1,
  [ dt.bit4 ] = 1,
  [ dt.int8 ] = 1,
  [ dt.uint8 ] = 1,
  [ dt.int16 ] = 1,
  [ dt.uint16 ] = 1,
  [ dt.float16 ] = 1,
  [ dt.int32 ] = 2,
  [ dt.uint32 ] = 2,
  [ dt.float32 ] = 2,
  [ dt.int64 ] = 4,
}

local maxpdu = 253
local maxaddr = 0x10000
local coilon = 0xFF00

local limits = {
  readbits = 2000,
  writebits = 1968,
  readregisters = 125,
  writeregisters = 123,
}

local excodes = {
  illegalfunction = 1,
  illegaldataaddress = 2,
  illegaldatavalue = 3,
  serverdevicefailure = 4,
  acknowledge = 5,
  serverdevicebusy = 6,
  negativeacknowledge = 7,
  memoryparityerror = 8,
  gatewaypathunavailable = 10,
  gatewaytargetdevicefailed = 11,
}

local handlers = {}

local function touint8(buf, off)
  return buf:byte(off)
end

local function touint16(buf, off, swap)
  local b1, b2 = buf:byte(off, off + 1)

  if swap then
    b1, b2 = b2, b1
  end

  return b1 * 0x100 + b2
end

local function getmapping(slaveid, fncode)
  local mapfncode = mapfncodes[ fncode ]
  local slave = mapping[ slaveid ]

  if not slave then
    slave = mapping['*']
  end

  if slave then
    return slave[ mapfncode ]
  end
end

local function readbits(slaveid, fncode, data)
  if #data ~= 4 then
    return
  end

  local addr = touint16(data, 1)
  local count = touint16(data, 3)

  if count == 0 or count > limits.readbits then
    return excodes.illegaldataaddress
  elseif (addr + count) > maxaddr then
    return excodes.illegaldataaddress
  end

  local map = getmapping(slaveid, fncode)

  if not map then
    return excodes.illegaldataaddress
  end

  local res = {}
  local bits, byte = 0, 0

  for i = 0, (count - 1) do
    local mapaddr = map[ addr + i ]

    if mapaddr then
      local bval = grp.getvalue(mapaddr)

      if toboolean(bval) then
        byte = byte + bit.lshift(1, bits)
      end
    end

    bits = bits + 1

    if bits == 8 then
      res[ #res + 1 ] = string.char(byte)
      bits, byte = 0, 0
    end
  end

  if bits ~= 0 then
    res[ #res + 1 ] = string.char(byte)
  end

  return string.char(#res) .. table.concat(res)
end

local zeroreg = string.char(0, 0)

local function readvalue(res, value, dpt)
  if dpt == dt.float16 then
    value = value * f16mult
    dpt = dt.int16
  end

  local enc = busdatatype.encode(value, dpt)
  if not enc.dataraw then
    return
  end

  local raw = enc.dataraw

  if #raw % 2 == 1 then
    raw = string.char(0) .. raw
  end

  local words = #raw / 2
  local offset = #res

  for i = 1, words do
    local word = raw:sub(i * 2 - 1, i * 2)

    if byteswap then
      word = word:sub(2, 2) .. word:sub(1, 1)
    end

    if wordswap then
      res[ offset + i ] = word
    else
      res[ offset + 1 + words - i ] = word
    end
  end

  return true
end

local function readregisters(slaveid, fncode, data)
  if #data ~= 4 then
    return
  end

  local addr = touint16(data, 1)
  local count = touint16(data, 3)

  if count == 0 or count > limits.readregisters then
    return excodes.illegaldataaddress
  elseif (addr + count) > maxaddr then
    return excodes.illegaldataaddress
  end

  local map = getmapping(slaveid, fncode)

  if not map then
    return excodes.illegaldataaddress
  end

  local res = {}
  local max = count - 1

  while #res <= max do
    local mapobj = map[ addr + #res ]
    local success

    if mapobj then
      local value = grp.getvalue(mapobj.address)

      if type(value) == 'boolean' then
        value = value and 1 or 0
      end

      if type(value) ~= 'number' then
        alert('invalid value ' .. mapobj.address .. ' ' .. tostring(value))
        value = 0
      end

      success = readvalue(res, value, mapobj.dpt)
    end

    if not success then
      res[ #res + 1 ] = zeroreg
    end
  end

  if #res ~= count then
    return excodes.illegaldataaddress
  end

  return string.char(count * 2) .. table.concat(res)
end

local function writevalue(obj, data, offset)
  local words = obj.len
  local len = words * 2
  local raw = data:sub(offset, offset + len - 1)

  if #raw < len then
    return
  end

  local buf = {}

  for i = 1, words do
    local word = touint16(raw, i * 2 - 1, byteswap)
    local hex = string.format('%04X', word)

    if wordswap then
      buf[ #buf + 1 ] = hex
    else
      table.insert(buf, 1, hex)
    end
  end

  local hexval = table.concat(buf)
  local dpt = obj.dpt == dt.float16 and dt.int16 or obj.dpt
  local value = busdatatype.decode(hexval, dpt)

  if obj.dpt == dt.float16 then
    value = value / f16mult
  end

  grp.write(obj.address, value)
end

handlers.readcoils = readbits
handlers.readdiscreteinputs = readbits

handlers.readregisters = readregisters
handlers.readinputregisters = readregisters

handlers.writecoil = function(slaveid, fncode, data)
  if #data ~= 4 then
    return
  end

  local addr = touint16(data, 1)
  local map = getmapping(slaveid, fncode)

  if not map or not map[ addr ] then
    return excodes.illegaldataaddress
  end

  local bval = touint16(data, 3) == coilon
  grp.write(map[ addr ], bval, dt.bool)

  return data
end

handlers.writeregister = function(slaveid, fncode, data)
  if #data ~= 4 then
    return
  end

  local addr = touint16(data, 1)
  local map = getmapping(slaveid, fncode)

  if not map or not map[ addr ] then
    return excodes.illegaldataaddress
  end

  local mapobj = map[ addr ]

  if mapobj.len ~= 1 then
    return excodes.illegaldataaddress
  end

  writevalue(mapobj, data, 3)

  return data
end

handlers.writecoils = function(slaveid, fncode, data)
  if #data < 5 then
    return
  end

  local addr = touint16(data, 1)
  local count = touint16(data, 3)
  local bytes = touint8(data, 5)

  if #data ~= (bytes + 5) then
    return
  end

  if count == 0 or count > limits.writebits or bytes ~= math.ceil(count / 8) then
    return excodes.illegaldataaddress
  end

  local map = getmapping(slaveid, fncode)

  if not map then
    return excodes.illegaldataaddress
  end

  local offset = 6
  local byte = data:byte(offset)
  local bits = 0

  for i = 0, (count - 1) do
    local bval = bit.band(bit.rshift(byte, bits), 1)
    local mapaddr = map[ addr + i ]

    if mapaddr then
      grp.write(mapaddr, bval, dt.bool)
    end

    bits = bits + 1
    if bits == 8 then
      bits = 0
      offset = offset + 1
      byte = data:byte(offset)
    end
  end

  return data:sub(1, 4)
end

handlers.writeregisters = function(slaveid, fncode, data)
  if #data < 5 then
    return
  end

  local addr = touint16(data, 1)
  local count = touint16(data, 3)
  local bytes = touint8(data, 5)

  if #data ~= (bytes + 5) then
    return
  end

  if count == 0 or count > limits.writeregisters or bytes ~= count * 2 then
    return excodes.illegaldataaddress
  end

  local map = getmapping(slaveid, fncode)

  if not map then
    return excodes.illegaldataaddress
  end

  for i = 0, (count - 1) do
    local mapobj = map[ addr + i ]

    if mapobj and not mapobj.readonly then
      local offset = 6 + i * 2
      writevalue(mapobj, data, offset)
    end
  end

  return data:sub(1, 4)
end

exception.tcp = function(sock, hdr, excode)
  local fncode = bit.bor(0x80, hdr:byte(8))
  local resp = hdr:sub(1, 4) ..
    string.char(0, 3) ..
    hdr:sub(7, 7) ..
    string.char(fncode, excode)

  return sock:send(resp)
end

exception.rtu = function(mbrtu, hdr, excode)
  local fncode = bit.bor(0x80, hdr:byte(2))
  local resp = hdr:sub(1, 1) ..
    string.char(fncode, excode)

  return mbrtu:send(resp)
end

reply.tcp = function(sock, hdr, data)
  local resp = hdr:sub(1, 4) ..
    string.char(0, #data + 2) ..
    hdr:sub(7, 8) .. data

  return sock:send(resp)
end

reply.rtu = function(mbrtu, hdr, data)
  local resp = hdr:sub(1, 2) .. data

  return mbrtu:send(resp)
end

local function handler(mode, ctx, hdr, data)
  local slaveid, fncode = hdr:byte(#hdr - 1, #hdr)
  local fnname = fncodes[ fncode ]

  if not fnname then
    return exception[ mode ](ctx, hdr, excodes.illegalfunction)
  end

  local res = handlers[ fnname ](slaveid, fncode, data)

  if type(res) == 'number' then
    return exception[ mode ](ctx, hdr, res)
  elseif type(res) == 'string' then
    return reply[ mode ](ctx, hdr, res)
  end
end

_M.tcphandler = function(sock)
  local hdr, err = sock:receive(8)

  if not hdr then
    return nil, err
  end

  local len = touint16(hdr, 5) - 2
  local data

  if len <= 0 or len > maxpdu then
    err = 'protocol error'
  else
    data, err = sock:receive(len)
  end

  if not data then
    return nil, err
  end

  return handler('tcp', sock, hdr, data)
end

_M.rtuhandler = function(mbrtu)
  local data, err = mbrtu:receive()

  if not data then
    return nil, err
  end

  local slaveid = data:byte(1)
  if not mapping[ slaveid ] then
    return
  end

  return handler('rtu', mbrtu, data:sub(1, 2), data:sub(3, #data - 2))
end

_M.setswap = function(swap)
  byteswap = swap:find('b') ~= nil
  wordswap = swap:find('w') ~= nil
end

_M.setfloat16precision = function(prec)
  prec = tonumber(prec) or 0
  f16mult = math.pow(10, prec)
end

local function initobject(obj, dbobj)
  local dpt = obj.datatype or dbobj.datatype

  if type(dpt) == 'string' then
    dpt = dt[ dpt ] or 0
  end

  if type(dpt) == 'number' and dpt >= 1000 then
    dpt = math.floor(dpt / 1000)
  end

  obj.dpt = dpt
  obj.len = dtlens[ dpt ]
  obj.address = dbobj.address

  if not obj.len then
    alert('invalid data type ' .. obj.address .. ' ' .. tostring(dpt))
    return
  end

  return obj
end

local function initobjects(objects)
  if type(objects) ~= 'table' then
    return
  end

  for addr, obj in pairs(objects) do
    if type(obj) == 'string' then
      obj = { address = obj }
    end

    local dbobj = grp.find(obj.address)
    if dbobj then
      obj = initobject(obj, dbobj)
    else
      alert('missing object ' .. obj.address)
      obj = nil
    end

    objects[ addr ] = obj
  end
end

_M.setmapping = function(map)
  for _, smap in pairs(map) do
    initobjects(smap.registers)
    initobjects(smap.inputregisters)
  end

  mapping = map
end

return _M

Modbus TCP #

For Modbus TCP slave create resident script with 0 interval (Scripting > Resident) and set object mapping in the table. Remember to create a gap in registers for 32 and 64 bit objects.

local mb = require('user.mbslave')
local copas = require('copas')
local socket = require('socket')
local address = '*'
local port = 502
local server = assert(socket.bind(address, port))

mb.setmapping({
  ['*'] = {
    coils = {
      [0] = '0/0/1',
      [1] = '1/1/1',
      [2] = '1/1/2',
    },
    registers = {
      [0] = '1/1/4',
      [1] = '1/1/10',
      [3] = '32/1/6',
      [5] = '32/1/13',
    }
  }
})

mb.setswap('w')
mb.setfloat16precision(2)

local function handler(sock)
  copas.setErrorHandler(log)

  sock = copas.wrap(sock)
  sock:settimeout(60)

  while true do
    local res, err = mb.tcphandler(sock)
    if not res then
      break
    end
  end

  sock:close()
end

copas.addserver(server, handler, 60)
copas.loop()

Modbus RTU #

For Modbus RTU slave create resident script with 0 interval (Scripting > Resident) and set object mapping in the table. Remember to create a gap in registers for 32 and 64 bit objects. Change serial port settings in mbrtu:open() as needed.

local mb = require('user.mbslave')
local mbrtu = require('luamodbus').rtu()

mbrtu:open('/dev/RS485-1', 9600, 'E', 8, 1, 'H')
mbrtu:connect()
mbrtu:setslave('*') -- '*' handles multiple RTU slave IDs

mb.setmapping({
  [1] = {
    coils = {
      [0] = '0/0/1',
      [1] = '1/1/1',
      [2] = '1/1/2',
    },
    registers = {
      [0] = '1/1/4',
      [1] = '1/1/10',
      [3] = '32/1/6',
      [5] = '32/1/13',
    }
  }
})

mb.setswap('w')
mb.setfloat16precision(2)

while true do
  mb.rtuhandler(mbrtu)
end

Further assistance can be found in this forum thread