SMS messages

Control and event notifications via SMS messages #

Task #

You are using LogicMachine with a 3G/4G modem and would like to control LogicMachine or retrieve data via SMS.

Init script for old CPU #

If you are using an old CPU (iMX28) then you need to add these lines to the Start-up (init) script and reboot your LogicMachine once.

os.execute('echo 1 > /sys/bus/platform/devices/ci_hdrc.0/force_full_speed')
os.execute('echo 1 > /sys/bus/platform/devices/ci_hdrc.1/force_full_speed')
os.execute('usbreset /dev/bus/usb/001/001')

User library #

Go to Scripting > User libraries and create a new user library called sms. Copy/paste the following code into the scripting editor.

AT = {
  -- 7-bit alphabet
  alphabet = {
    64, 163, 36, 165, 232, 233, 249, 236, 242, 199, 10, 216, 248,
    13, 197, 229, 10, 95, 10, 10, 10, 10, 10, 10, 10, 10, 10, 38,
    198, 230, 223, 201, 32, 33, 34, 35, 164, 37, 38, 39, 40, 41,
    42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56,
    57, 58, 59, 60, 61, 62, 63, 161, 65, 66, 67, 68, 69, 70, 71,
    72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86,
    87, 88, 89, 90, 196, 214, 209, 220, 167, 191, 97, 98, 99, 100,
    101, 102, 103,  104, 105, 106, 107, 108, 109, 110, 111, 112,
    113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 228, 246,
    241, 252, 224
  },
  ucs2toutf8 = function(str)
    local res = {}

    str = lmcore.hextostr(str, true)

    local function decode(val)
      if val < 0x80 then
        res[ #res + 1 ] = val
      elseif val < 0x800 then
        res[ #res + 1 ] = bit.bor(0xC0, bit.rshift(val, 6))
        res[ #res + 1 ] = bit.bor(0x80, bit.band(val, 0x3F))
      else
        res[ #res + 1 ] = bit.bor(0xE0, bit.rshift(val, 12))
        res[ #res + 1 ] = bit.bor(0x80, bit.band(bit.rshift(val, 6), 0x3F))
        res[ #res + 1 ] = bit.bor(0x80, bit.band(val, 0x3F))
      end
    end

    local len = math.floor(#str / 2) * 2
    for i = 1, len, 2 do
      local b1, b2 = str:byte(i, i + 1)
      decode(b1 * 0x100 + b2)
    end

    return string.char(unpack(res))
  end,
  parsepdu = function(pdu)
    local data, len, msg, data, sender, offset, ntype, timestamp, dcs, ucs2

    msg = {}

    -- offset from service center number
    offset = tonumber(pdu:sub(1, 2), 16) * 2

    -- sender number length
    len = tonumber(pdu:sub(offset + 5, offset + 6), 16)
    len = math.ceil(len / 2) * 2

    -- sender number type
    ntype = tonumber(pdu:sub(offset + 7, offset + 8), 16)
    ntype = bit.band(bit.rshift(ntype, 4), 0x07)

    -- raw sender number
    sender = pdu:sub(offset + 9, offset + len + 8)

    -- decode sender number
    msg.sender = AT.decodesender(sender, ntype)

    offset = offset + len + 11
    dcs = tonumber(pdu:sub(offset, offset + 1))
    dcs = bit.band(dcs, 0x0F)
    ucs2 = 0x08 <= dcs and dcs <= 0x0B

    -- timestamp
    offset = offset + 2
    timestamp = pdu:sub(offset, offset + 13)
    timestamp = AT.decodeswapped(timestamp)

    msg.timestamp = AT.decodetime(timestamp)

    -- message
    len = tonumber(pdu:sub(offset + 14, offset + 15), 16)
    data = pdu:sub(offset + 16)

    if ucs2 then
      msg.data = AT.ucs2toutf8(data)
    else
      msg.data = AT.decode7bit(data, len)
    end

    return msg
  end,
  -- decode sender address depending on source type
  decodesender = function(sender, ntype)
    if ntype == 5 then
      return AT.decode7bit(sender)
    else
      return AT.decodeswapped(sender)
    end
  end,
  -- decode time in sms pdu
  decodetime = function(timestamp)
    local offset, year, time

    offset = tonumber(timestamp:sub(13, 14)) or 0
    offset = offset * 15 * 60

    year = tonumber(timestamp:sub(1, 2))

    time = os.time({
      year = year < 70 and (2000 + year) or (1900 + year),
      month = tonumber(timestamp:sub(3, 4)),
      day = tonumber(timestamp:sub(5, 6)),
      hour = tonumber(timestamp:sub(7, 8)),
      min = tonumber(timestamp:sub(9, 10)),
      sec = tonumber(timestamp:sub(11, 12))
    }) or os.time()

    return time
  end,
  -- convert swapped number to normal
  decodeswapped = function(data)
    local i, nr, len, buf
    buf = {}

    -- real byte length
    len = math.floor(data:len() / 2)
    -- read 2 bytes at once
    for i = 1, len do
      -- convert low byte to number
      nr = tonumber(data:sub(i * 2, i * 2))
      if nr then
        table.insert(buf, tostring(nr))
      end

      -- convert high byte to number
      nr = tonumber(data:sub(i * 2 - 1, i * 2 - 1))
      if nr then
        table.insert(buf, tostring(nr))
      end
    end

    return table.concat(buf)
  end,
  -- convert from 7 bit char to 8 bit
  from7bit = function(c)
    if c < 128 then
      return string.char(AT.alphabet[ c + 1 ])
    else
      return ' '
    end
  end,
  -- converts from 7 bit to 8 bit
  decode7bit = function(data, len)
    local i, o, byte, prev, curr, mask, buf, res

    -- convert to binary string
    data = lmcore.hextostr(data, true)

    -- init vars
    o = 0
    prev = 0
    buf = {}

    for i = 1, data:len() do
      byte = data:byte(i, i)

      -- get 7 bit data
      mask = bit.lshift(1, 7 - o) - 1

      -- get current chunk
      curr = bit.band(byte, mask)
      curr = bit.lshift(curr, o)
      curr = bit.bor(curr, prev)

      -- save bit chunk
      prev = bit.rshift(byte, 7 - o)

      -- add to buffer
      table.insert(buf, AT.from7bit(curr))

      -- every 7th step prev will have a full char
      if o == 6 then
        table.insert(buf, AT.from7bit(prev))
        prev = 0
      end

      o = (o + 1) % 7
    end

    -- catch last char in buffer
    if prev > 0 then
      table.insert(buf, AT.from7bit(prev))
    end

    -- flatten buffer
    res = table.concat(buf)
    if len then
      res = res:sub(1, len)
    end

    return res
  end
}

function AT:init(dev, reset)
  require('serial')

  local t = setmetatable({}, { __index = AT })

  t.dev = dev

  while true do
    if t:reinit(30, reset) then
      break
    else
      if reset then
        t:reset()
      else
        return nil
      end
    end
  end

  return t
end

function AT:open(timeout)
  local port

  while true do
    if io.exists(self.dev) then
      port = serial.open(self.dev)

      if port then
        break
      end
    end

    if timeout then
      timeout = timeout - 1

      if timeout <= 0 then
        return false
      end
    end

    os.sleep(1)
  end

  self.port = port
  self.buffer = {}

  return true
end

function AT:reset()
  alert('modem reset')
  os.execute('usbreset /dev/bus/usb/001/001')
end

function AT:reinit(timeout, reset)
  local res

  if reset then
    self:reset()
  end

  res = self:open(timeout)
  self.buffer = {}

  return res
end

function AT:close()
  if self.port then
    self.port:close()
    self.port = nil
  end
end

-- read single line from port
function AT:read(timeout)
  local char, err, timeout, deftimeout, line

  -- default timeout is 1 second, converted to 0.1 sec ticks
  timeout = tonumber(timeout) or 1
  timeout = timeout * 10

  deftimeout = timeout

  -- read until got one line or timeout occured
  while timeout > 0 do
    -- read 1 char
    char, err = self.port:read(1, 0.1)

    -- got data
    if char then
      -- got LF, end of line
      if char == '\n' then
        -- convert to string and empty buffer
        line = table.concat(self.buffer)
        self.buffer = {}
        line = line:trim()

        -- return only lines with data
        if #line > 0 then
          return line
        -- reset timeout
        else
          timeout = deftimeout
        end
      -- ignore CR
      elseif char ~= '\r' then
        table.insert(self.buffer, char)
      end
    -- read timeout
    elseif err == 'timeout' then
      timeout = timeout - 1
    -- other error
    else
      break
    end
  end

  print('error', err)
  return nil, err
end

-- blocking read until cmd is received
function AT:readuntil(cmd, timeout)
  local line, err
  timeout = timeout or 5

  while timeout > 0 do
    line, err = self:read()

    -- read line ok
    if line then
      if line == cmd or line == 'COMMAND NOT SUPPORT' or line:match('ERROR') then
        return line
      else
        timeout = timeout - 1
        err = 'invalid line'
      end
    -- timeout
    elseif err == 'timeout' then
      timeout = timeout - 1
    -- other error
    else
      break
    end
  end

  return nil, err
end

-- send command to terminal
function AT:send(cmd)
  local res, err = self.port:write(cmd .. '\r\n')

  -- write ok, get local echo
  if res then
    res, err = self:readuntil(cmd)
    self:read()
  end

  return res, err
end

-- main handler
function AT:run()
  local res, err, cmd, pos, sms

  res, err = self:read()
  if err then
    return err == 'timeout'
  end

  -- check for incoming command
  if res:sub(1, 1) ~= '+' then
    return true
  end

  pos = res:find(':', 1, true)

  if not pos then
    return true
  end

  -- get command type
  cmd = res:sub(2, pos - 1)

  -- check only for incoming sms
  if cmd == 'CMTI' then
    -- read from sim
    sms = self:incsms(res)

    -- sms seems to be valid, pass to handler if specified
    if sms and self.smshandler then
      self.smshandler(sms)
    end
  end

  return true
end

-- incoming sms handler
function AT:incsms(res)
  local chunks, index, sms

  -- get message index from result
  chunks = res:split(',')
  if #chunks == 2 then
    -- get index and read from it
    index = tonumber(chunks[ 2 ])
    sms = self:readsms(index)
    -- delete sms from store
    self:deletesms(index)
  end

  return sms
end

-- delete sms at index
function AT:deletesms(index)
  local cmd, res

  -- send delete request
  cmd = 'AT+CMGD=' .. index
  res = self:send(cmd)

  return res
end

-- read sms at index
function AT:readsms(index)
  local cmd, res, sms

  -- send read request
  cmd = 'AT+CMGR=' .. index
  res = self:send(cmd)

  -- no message at then index
  if res == 'OK' then
    return nil, 'not found'
  end

  -- read sms pdu and try decoding
  sms = self:read()
  res, sms = pcall(AT.parsepdu, sms)

  -- decode failed
  if not res then
    return nil, sms
  end

  -- wait for ok from modem
  self:readuntil('OK')

  return sms
end

function AT:sendsms(number, message)
  local cmd, res

  -- switch to text mode
  self:send('AT+CMGF=1')

  -- set number
  cmd = string.format('AT+CMGS="%s"', number)
  res = self:send(cmd)

  -- number seems to be valid
  if res ~= 'ERROR' then
    -- message and CTRL+Z
    self.port:write(message .. string.char(0x1A))
    res = self:readuntil('OK')
  end

  -- switch back to pdu mode
  self:send('AT+CMGF=0')

  return res
end

-- set sms handler
function AT:setsmshandler(fn)
  if type(fn) == 'function' then
    self.smshandler = fn
  end
end

table.contains = function(t, v)
  for _, i in pairs(t) do
    if i == v then
      return true
    end
  end
end

function sendsms(number, message)
  require('socket')
  client = socket.udp()
  client:sendto(number .. ' ' .. message, '127.0.0.1', 12535)
end

Resident script #

Go to Scripting > Resident and create a new Resident script for the SMS handler. Set the sleep interval to 0. Copy/paste the following code into the scripting editor.

Edit the script and set the PIN and allowed numbers for the SMS handler. You might also need to change the port name (ttyACM3) to ttyUSB2 depending on the hardware model.

-- init
if not numbers then
  require('user.sms')
  require('json')
  require('socket')

  -- allowed numbers, SMS from other numbers will be ignored
  numbers = { '12345678' }

  -- port number depends on modem model ttyUSB2 or ttyACM3
  comport = 'ttyACM3'
  doreset = false -- set to true if USB reset is required before starting any communication

  -- if SIM PIN is enabled, uncomment the line below and replace 0000 with SIM PIN
  -- pincode = '0000'

  -- command parser
  parser = function(cmd, sender)
    local find, pos, name, mode, offset, value, dvalue, obj, message

    cmd = cmd:trim()
    mode = cmd:sub(1, 1):upper()

    -- invalid request
    if mode ~= 'W' and mode ~= 'R' then
      return
    end

    cmd = cmd:sub(3):trim()

    -- parse object name/address
    find = cmd:sub(1, 1) == '"' and '"' or ' '
    offset = find == '"' and 1 or 0

    -- pad with space when in read mode
    if mode == 'R' and find == ' ' then
      cmd = cmd .. ' '
    end

    -- find object name
    pos = cmd:find(find, 1 + offset, true)

    -- name end not found, stop
    if not pos then
      return
    end

    -- get name part
    name = cmd:sub(1 + offset, pos - offset):trim()

    if mode == 'W' then
      value = cmd:sub(pos + offset):trim()

      if #value > 0 then
        -- try decoding value
        dvalue = json.pdecode(value)
        if dvalue ~= nil then
          value = dvalue
        end

        -- send to bus
        grp.write(name, value)
      end
    -- read request
    else
      obj = grp.find(name)

      -- object not known
      if not obj then
        return
      end

      -- send read request and wait for an update
      obj:read()
      os.sleep(1)

      -- read new value
      value = grp.getvalue(name)

      -- got no value
      if value == nil then
        return
      end

      -- add object name if specified
      if obj.name then
        name = string.format('%s (%s)', obj.name, obj.address)
      end

      message = string.format('Value of %s is %s', name, json.encode(value))
      modem:sendsms('+' .. sender, message)
    end
  end

  -- incoming sms handler
  handler = function(sms)
    alert('incoming sms: [%s] %s', tostring(sms.sender), tostring(sms.data))

    -- sms from known number, call parser
    if table.contains(numbers, sms.sender) then
      parser(sms.data, sms.sender)
    end
  end

  -- check local udp server for messages to send
  udphandler = function(server)
    -- check for local sms to send
    local msg = server:receive()
    -- got no message
    if not msg then
      return
    end

    -- split into number and message
    local sep = msg:find(' ')
    if not sep then
      return
    end

    alert('sending sms: ' .. msg)
    modem:sendsms(msg:sub(1, sep - 1), msg:sub(sep + 1))
  end
end

-- handle data from modem
if modem then
  if modem:run() then
    udphandler(server)
  else
    alert('SMS handler lost connection')
    modem:reinit()
  end
-- modem init
else
  alert('SMS handler init')
  -- open serial port
  modem = AT:init('/dev/' .. comport, doreset)

  -- init ok
  if modem then
    -- set sms handler
    modem:setsmshandler(handler)

    -- send pin if set
    if pincode then
      modem:send('AT+CPIN=' .. pincode)
      modem:read()
    end

    -- set to pdu mode
    modem:send('AT+CMGF=0')
    -- enable sms notifications
    modem:send('AT+CNMI=1,1,0,0,0')
    -- fixup encoding
    modem:send('AT+CSCS="GSM"')
    -- delete all saved messages
    modem:send('AT+CMGD=1,4')

    -- local udp server for sending sms
    server = socket.udp()
    server:setsockname('127.0.0.1', 12535)
    server:settimeout(0.1)

    alert('SMS handler started')
  -- init failed
  else
    alert('SMS USB init failed')
  end
end

SMS script example #

require('user.sms')

number = '12345678'
message = 'test sms'

sendsms(number, message)

SMS command syntax #

Write to bus: W ALIAS value

Read from bus: R ALIAS

On read request, script will reply with an SMS message containing the current value of selected object.

ALIAS can be #

  • Group address (e.g. 1/1/1)
  • Name (e.g. Obj1). If name contains spaces then it must be escaped using double quotes (e.g. "Room Temperature")

NOTE #

  • Object data type and name must be set in the Objects tab. Otherwise script won't be able to read and write to object
  • Only ASCII symbols are accepted in the message

Examples #

  • Binary write: W 1/1/1 true
  • 0-100% scaling write: W "LED1 Red" 67
  • Temperature (floating point) write: W "Room Setpoint" 22.5
  • Read: R 2/1/1