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/2, depending on settings)
  • 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 then 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 f16float = false
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
    if f16float then
      dpt = dt.float32
    else
      value = value * f16mult
      dpt = dt.int16
    end
  end

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

  local raw = enc.dataraw

  if #raw % 2 == 1 then
    local pad = value < 0 and 0xFF or 0
    raw = string.char(pad) .. 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

  if dpt == dt.float16 then
    dpt = f16float and dt.float32 or dt.int16
  end

  local value = busdatatype.decode(hexval, dpt)

  if obj.dpt == dt.float16 and not f16float 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

_M.setfloat16mode = function(mode)
  f16float = mode == 'float'
  dtlens[ dt.float16 ] = f16float and 2 or 1
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.setswap('w')
mb.setfloat16precision(2)

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',
    }
  }
})

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.setswap('w')
mb.setfloat16precision(2)

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',
    }
  }
})

while true do
  mb.rtuhandler(mbrtu)
end

Additional settings #

mb.setswap(swap) #

Sets the byte/word swap mode. Default mode is no swap, possible values are b (byte swap), w (word swap) and bw (byte and word swap). Default mode is no byte and word swap.


The following functions must be called before calling mb.setmapping

mb.setfloat16mode(mode) #

Sets how float16 values are treated. If mode is set to 'float' (string) then values are converted to float32 on Modbus side and use two registers per each value. Default mode is conversion to int16 with an optional precision setting.


mb.setfloat16precision(precision) #

precision controls the number of decimal places of float16 values in integer mode (default). For example 20.5 is converted to 2050 when precision is set to 2.


Further assistance can be found in this forum thread