LogicMachine Forum
LM as modbus slave - Printable Version

+- LogicMachine Forum (https://forum.logicmachine.net)
+-- Forum: LogicMachine eco-system (https://forum.logicmachine.net/forumdisplay.php?fid=1)
+--- Forum: Gateway (https://forum.logicmachine.net/forumdisplay.php?fid=10)
+--- Thread: LM as modbus slave (/showthread.php?tid=5913)



LM as modbus slave - gdimaria - 06.03.2025

Hi, 

I have to set LM as modbus slave to a master modbus device who must be able to read and write on KNX objects.

How should I proceed?

Thanks

Peppe


RE: LM as modbus slave - admin - 06.03.2025

https://kb.logicmachine.net/integration/modbus-rtu-tcp-slave/


RE: LM as modbus slave - gdimaria - 06.03.2025

I followed the instructions, but testing with the modbus tool, it times out and does not read the registers. 

I attach the resident script and the mbslave library that I made.

MODBUS RTU SLAVE resident script 

Code:
require('user.mbslave')


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

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

mb.setmapping({
  [1] = {
      coils = {

      [1] = '2/1/11', -- stato on/off
      [4] = '2/1/14', -- stato set %
    },
    registers = {
      [0] = '2/1/10', -- comando on/off
     -- [1] = '2/1/11', -- stato on/off
      [3] = '2/1/13', -- set %
     -- [4] = '2/1/14', -- stato set %
    }
  }
})

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

while true do
  mb.rtuhandler(mbrtu)
end

user.mbslave


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


Thanks in advance

Peppe


RE: LM as modbus slave - admin - 06.03.2025

For testing purposes you can connect both LM RS485 ports together. Then use Read test in Modbus tab. Make sure that port settings match.


RE: LM as modbus slave - gdimaria - 07.03.2025

so, you are saying to connect both LM RS485 each other and then use the read test.... on which of 3 rtu? is that correct?

by the way, in mbrtu:open('/dev/RS485-1', 9600, 'E', 8, 1, 'H') what means 'H' ?

Thanks

Peppe


RE: LM as modbus slave - admin - 07.03.2025

Use RS485-1 in the script. Configure RTU1 to use RS485-2.
'E', 8, 1, 'H' means Even parity, 8 data bits, 1 stop bit and Half duplex mode


RE: LM as modbus slave - gdimaria - 07.03.2025

ok....

just to be sure

I have to connect 

RS485-1                        RS485-2

A                        to               B

B                        to               A


That's correct?


RE: LM as modbus slave - Daniel - 07.03.2025

No A-A, B-B


RE: LM as modbus slave - gdimaria - 07.03.2025

(07.03.2025, 10:15)Daniel Wrote: No A-A, B-B

ok thanks

Can I do the same with spaceLynk? how to connect and configure?


RE: LM as modbus slave - Daniel - 07.03.2025

No as it has only one RS485 port but if you have more than one you can connect two.


RE: LM as modbus slave - gdimaria - 07.03.2025

Last question:

I have some knx address as commands and others as states... how I have to set them? Both coil and registers as in my script ? or a different way?

Code:
mb.setmapping({
  [1] = {
      coils = {

      [1] = '2/1/11', -- state on/off
      [4] = '2/1/14', -- state set %
    },
    registers = {
      [0] = '2/1/10', -- command on/off
     -- [1] = '2/1/11', -- stato on/off
      [3] = '2/1/13', -- command set %
     -- [4] = '2/1/14', -- stato set %
    }
  }
})



RE: LM as modbus slave - Daniel - 07.03.2025

Coils are bits only, registers are numeric.
Coil and registers have its own ranges.


RE: LM as modbus slave - gdimaria - 10.10.2025

HI,

what is the max amount of register I can map on LM as modbus slave?


Code:
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] = '0/0/3',
      [2] = '0/0/4',
    },
    discreteinputs = {
      [0] = '1/0/1',
      [1] = '1/0/3',
      [2] = '1/0/4',
    },
    registers = {
      [0] = '1/1/4',
      [1] = '1/1/10',
      [3] = '32/1/6',
      [5] = '32/1/13',
    },
    inputregisters = {
      [0] = '2/1/4',
      [1] = '2/1/10',
    }
  }
})

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()



RE: LM as modbus slave - admin - 13.10.2025

There's no hard limit on how many registers can be mapped. How many do you need to map and how often these registers will be polled?


RE: LM as modbus slave - gdimaria - 13.10.2025

it's about 4000 GA (command and status), almost lights, so I guess about 3 sec.

I have to transfer the control and visualization of knx via modbus on a ABB SCADA. It can read up to 10k registers


RE: LM as modbus slave - Daniel - 13.10.2025

This won't work, its much too much. Don't they have any other interface? Modbus is the worst possible option for lights. and so many objects.


RE: LM as modbus slave - gdimaria - 14.10.2025

(13.10.2025, 15:41)Daniel Wrote: This won't work, its much too much. Don't they have any other interface? Modbus is the worst possible option for lights. and so many objects.

the lights are in KNX, then, through the LM I have to pass them in Modbus to the ABB Scada


RE: LM as modbus slave - Daniel - 14.10.2025

It doesn't matter, 4k objects pooled every 3sec won't work.


RE: LM as modbus slave - gdimaria - 14.10.2025

(14.10.2025, 09:10)Daniel Wrote: It doesn't matter, 4k objects pooled every 3sec won't work.

I see.... so what would be the max number of GA mapped as modbus registers on LM in polling?


RE: LM as modbus slave - Daniel - 14.10.2025

This script wasn't optimized for such big integration. It is hard to say as we never tested it on a bigger scale.
100 objects every 5 sec should work but I'm guessing now.
Maybe some other user could say something.