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