Modbus RTU на роутері (LEDE OpenWRT & LUA)

В мене дома розведена мережа RS485, яка об’єднує лічильник електроенергії SDM230, годинник, метеостанцію, водяні лічильники. Протоколом обміну вибрано Modbus RTU, яка вимагає мастера, який ініціалізує звертання до пристроїв.  Канальна швидкість 9600 байт.

Можна викорстати апаратний міст, наприклад ComPoint II AS від AK-NORD на 2 порти, розглянемо його: конектор DB-9. Може бути або 2 RS-232 або RS232+RS485.

З іншої сторони LAN 10/100 mbit, індикатори статусів, конектор живлення.

Живлення на LDO, запустилася і працювала від 5 вольт. Є версії на імпульскнику. Контролер 16 бітний від NXP MC9S12NE64VTUE.

Інтерфейс RS-232 ADM560. Драйвер RS-485 на Sipex 3072E. Перемичками виставляється режим роботи і підятгуючі резистори.

Конфігурація текстова навіть через web, пароль XT. DHCP імя XT-MICRO-AABBCC.

А можна заставити роутер бути і шлюзом і керувати логікою.

Оскільки є в наявності роутер MR3020 із LEDE 17.01.7 (OpenWRT) із збільшеним flash з 8 мб (на 4 мб не поміститься) і оперативною пам’ятю 32 мб замість 16, який має USB порт, підключаємо китайський USB-RS485 адаптер ціною 1$.

Оскільки ресурси роутера обмежені, використаємо інтерпретатор, який вже інстальований, в даному випадку це LUA. Синтаксис специфічний (не дорівнює ~=, є тип даних nil). Довстановлюємо потрібні модулі:

opkg update
opkg install kmod-usb-serial 
opkg install kmod-usb-serial-ch341
opkg install lua-rs232

Тепер можемо писати наш файл програми modbus.lua. І почнемо з функцій.

Відкриємо порт із швидкістю 9600, перед тим перевіривши чи адаптер розпізнався, переважно /dev/ttyUSB0:

local function openport()
  local out = nil
  e, p = rs232.open("/dev/ttyUSB0")
  if e ~= rs232.RS232_ERR_NOERROR then
    print(string.format("Can't open serial port %s, %s", "ttyUSB0", rs232.error_tostring(e)))
  else
   onport = true
   p:set_baud_rate(rs232.RS232_BAUD_9600)
   p:set_data_bits(rs232.RS232_DATA_8)
   p:set_parity(rs232.RS232_PARITY_NONE)
   p:set_stop_bits(rs232.RS232_STOP_1)
   p:set_flow_control(rs232.RS232_FLOW_OFF)
   out = p
  end
  return out
end

Прокол Modbus RTU для контролю цілісності пакетів використовує 2 байтну CRC-16. Перевірити коректність формування контрольну суму можна на сайті crccalc.com. Тому пишемо калькулятор суми, який на вході отримує масив байт:

local function crc16(s)
  local crc = 0xffff
  for i,val in ipairs(s) do
    local c = val
    crc = bit.bxor(crc, c)
    for j = 1, 8 do
      local k = bit.band(crc, 1)
      crc = bit.rshift(crc, 1)
      if k ~= 0 then
        crc = bit.bxor(crc, 0xA001)
      end
    end
  end
  return crc
end

Алгоритм використовує бітову операцію сумування по модулю 2 XOR, використаємо бібліотеку бітових операцій numberlua.lua і покладемо у

/usr/lib/lua/bit/numberlua.lua

І фукнцію яка дописує контрольну суму до пакета даних:

local function crc16_add(req)
  crc = crc16(req)
  Hi = bit.rshift(crc, 8)
  Lo = crc - Hi*256
  table.insert(req, Lo)
  table.insert(req, Hi)
  return req
end

Напишемо функцію, яка запитує один регістр із пристрою функцією 04 Read Input Registers і вертатиме результат.

function modbus_req(addr, reg, bytes, IEEE754)
  local valid=1
  local func = 4
  reg_hi = bit.rshift(reg, 8)
  reg_lo = reg - reg_hi*256
  req = {addr, func, reg_hi, reg_lo, 0, bytes}
  req = crc16_add(req)
  port_w(req)
  res = port_r()
  if (res ~= nil) then
    if res[1] ~= addr then valid=0 end
    if res[2] ~= func then valid=0 end
    
    local datsize = res[3]
    local crc_now = res[datsize+5]*256+res[datsize+4]
    table.remove(res,datsize+5)
    table.remove(res,datsize+4)
    if (crc_now ~= crc16(res)) then
      valid=0
      print('Bad CRC for packet')
    end

    table.remove(res,3)
    table.remove(res,2)
    table.remove(res,1)

    if valid==1 then
      if (IEEE754 == true) then
        return UnpackIEEE754(res)
      else
        return res
      end
    else
      return nil
    end
  else
    return res
  end
end

Функція записує масив байт у фізичний порт:

function port_w(req)
  local data = ""
  for i,val in ipairs(req) do
    data = data .. string.char(val)
  end
  local err, len_written = p:write(data)
  p:flush()
  return len_written
end

І читання із порту і виходом з функції при відсутності відповіді:

function port_r()
  local out = nil
  local receive = 0
  local takt = 1
  local a=0
  while (a == 0) do
    local read_len, timeout = 1 , 100
    local err, data_read, size = p:read(read_len, timeout)
    if size > 0 then 
      if (receive == 0) then
         out = {string.byte(data_read)}
      else
        table.insert(out, string.byte(data_read))
      end
      receive = receive + size
    end
    takt = takt + 1
    if takt - receive > 2 then a=1 end
  end
  return out
end

Оскільки лічильник віддає дані у форматі IEEE754 перетворювач даних:

function UnpackIEEE754(x)
  local b1, b2, b3, b4 = x[1], x[2], x[3], x[4]
  local exponent = (b1 % 0x80) * 0x02 + math.floor(b2 / 0x80)
  local mantissa = math.ldexp(((b2 % 0x80) * 0x100 + b3) * 0x100 + b4, -23)
  if exponent == 0xFF then
    if mantissa > 0 then
      return 0 / 0
    else
      mantissa = math.huge
      exponent = 0x7F
    end
  elseif exponent > 0 then
    mantissa = mantissa + 1
  else
    exponent = exponent + 1
  end
  if b1 >= 0x80 then
    mantissa = -mantissa
  end
  return math.floor(math.ldexp(mantissa, exponent - 0x7F)*1000)/1000
end

Для запису у регістр функцією 06 Preset Single Register:

function modbus_w(addr, reg, val)
  local func = 6
  reg_hi = bit.rshift(reg, 8)
  reg_lo = reg - reg_hi*256
  val_hi = bit.rshift(val, 8)
  val_lo = val - val_hi*256
  req = {addr, func, reg_hi, reg_lo, val_hi, val_lo}
  req = crc16_add(req)
  port_w(req)
end

Тепер можна написати саму програму:

p = openport()
-- отримаємо показник лічильника і напругу 
meter = modbus_req(10,0x0156,2,true)
volts = modbus_req(10,0x0000,2,true)
-- отримані дані відправляємо через wget на сервер
local handle = io.popen("/bin/wget -q -O - 'http://****.net.ua/****.php?meter=" .. meter .. "&volts=" .. volts)
local result = handle:read("*a")
handle:close()
-- отримані дані записуємо на індикатор
modbus_w(45, 0x0000, result)
p:close()

Для періодичного запуску в певний час (наприклад щохвилини) пропишемо у crontab час запуску (для виконання має бути пустий останній рядок):

* * * * *  /usr/bin/lua /root/modbus.lua

Тепер на сервері робимо потрібну логіку збереження, обробки і віддачі потрібних команд.

Це швидка реалізація поставленного завдання з того шо було у наявності, зараз йде тестування шлюзу із логікою на базі ESP32 із GPRS модулем.

Цікаве відкриття: у Siemens SIMATIC S7 позначення ліній A і B не відповідає загально прийнятим. Просто навпаки.

Використання фотографій або текстового контенту на інших ресурсах без клікабельного індексованого посилання заборонено.