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