diff --git a/controller-client/socket-test.lua b/controller-client/socket-test.lua
new file mode 100644
index 0000000..8b3e03e
--- /dev/null
+++ b/controller-client/socket-test.lua
@@ -0,0 +1,19 @@
+local socket = require("socket")
+
+local conn = socket.connect("localhost", 1234)
+print("connected")
+
+conn:settimeout(3)
+
+local data, err, part = conn:receive(3)
+print("Data:", data)
+print("Err:", err)
+print("Part:", part)
+
+
+local data, err, part2 = conn:receive(3, part)
+print("Data:", data)
+print("Err:", err)
+print("Part:", part2)
+
+conn:close()
diff --git a/controller-host/mqtt/bitwrap.lua b/controller-host/mqtt/bitwrap.lua
index e52d5eb..cf56777 100644
--- a/controller-host/mqtt/bitwrap.lua
+++ b/controller-host/mqtt/bitwrap.lua
@@ -1,5 +1,6 @@
-- wrapper around BitOp module
+-- luacheck: globals jit
if _VERSION == "Lua 5.1" or type(jit) == "table" then -- Lua 5.1 or LuaJIT (based on Lua 5.1)
return require("bit") -- custom module https://luarocks.org/modules/luarocks/luabitop
elseif _VERSION == "Lua 5.2" then
diff --git a/controller-host/mqtt/client.lua b/controller-host/mqtt/client.lua
index 9241419..0b5e1f4 100644
--- a/controller-host/mqtt/client.lua
+++ b/controller-host/mqtt/client.lua
@@ -1,9 +1,6 @@
---- MQTT client module
--- @module mqtt.client
--- @alias client
-local client = {}
-
--- TODO: list event names
+--- This class contains the MQTT client implementation.
+-- @classmod Client
+local _M = {}
-------
@@ -20,16 +17,10 @@ local os_time = os.time
local string = require("string")
local str_format = string.format
local str_gsub = string.gsub
-local str_match = string.match
local table = require("table")
local table_remove = table.remove
-local coroutine = require("coroutine")
-local coroutine_create = coroutine.create
-local coroutine_resume = coroutine.resume
-local coroutine_yield = coroutine.yield
-
local math = require("math")
local math_random = math.random
@@ -49,52 +40,63 @@ local protocol5 = require("mqtt.protocol5")
local make_packet5 = protocol5.make_packet
local parse_packet5 = protocol5.parse_packet
-local ioloop = require("mqtt.ioloop")
-local ioloop_get = ioloop.get
+local log = require "mqtt.log"
-------
--- MQTT client instance metatable
--- @type client_mt
-local client_mt = {}
-client_mt.__index = client_mt
+local Client = {}
+Client.__index = Client
---- Create and initialize MQTT client instance
--- @tparam table args MQTT client creation arguments table
--- @tparam string args.uri MQTT broker uri to connect.
--- Expecting "host:port" or "host" format, in second case the port will be selected automatically:
--- 1883 port for plain or 8883 for secure network connections
--- @tparam string args.clean clean session start flag
--- @tparam[opt=4] number args.version MQTT protocol version to use, either 4 (for MQTT v3.1.1) or 5 (for MQTT v5.0).
--- Also you may use special values mqtt.v311 or mqtt.v50 for this field.
--- @tparam[opt] string args.id MQTT client ID, will be generated by luamqtt library if absent
--- @tparam[opt] string args.username username for authorization on MQTT broker
--- @tparam[opt] string args.password password for authorization on MQTT broker; not acceptable in absence of username
--- @tparam[opt=false] boolean,table args.secure use secure network connection, provided by luasec lua module;
--- set to true to select default params: { mode="client", protocol="tlsv1_2", verify="none", options="all" }
--- or set to luasec-compatible table, for example with cafile="...", certificate="...", key="..."
--- @tparam[opt] table args.will will message table with required fields { topic="...", payload="..." }
--- and optional fields { qos=1...3, retain=true/false }
--- @tparam[opt=60] number args.keep_alive time interval for client to send PINGREQ packets to the server when network connection is inactive
--- @tparam[opt=false] boolean args.reconnect force created MQTT client to reconnect on connection close.
--- Set to number value to provide reconnect timeout in seconds
--- It's not recommended to use values < 3
--- @tparam[opt] table args.connector connector table to open and send/receive packets over network connection.
--- default is require("mqtt.luasocket"), or require("mqtt.luasocket_ssl") if secure argument is set
--- @tparam[opt="ssl"] string args.ssl_module module name for the luasec-compatible ssl module, default is "ssl"
+--- Create and initialize MQTT client instance. Typically this is not called directly,
+-- but through `Client.create`.
+-- @tparam table opts MQTT client creation options table
+-- @tparam string opts.uri MQTT broker uri to connect. Expected format:
+--
`[mqtt[s]://][username[:password]@]hostname[:port]`
+--
Any option specifically added to the options
+-- table will take precedence over the option specified in this uri.
+-- @tparam boolean|string opts.clean clean session start flag, use "first" to start clean only on first connect
+-- @tparam[opt] string opts.protocol either `"mqtt"` or `"mqtts"`
+-- @tparam[opt] string opts.username username for authorization on MQTT broker
+-- @tparam[opt] string opts.password password for authorization on MQTT broker; not acceptable in absence of username
+-- @tparam[opt] string opts.host hostname of the MQTT broker to connect to
+-- @tparam[opt] int opts.port port number to connect to on the MQTT broker, defaults to `1883` port for plain or `8883` for secure network connections
+-- @tparam[opt=4] number opts.version MQTT protocol version to use, either `4` (for MQTT v3.1.1) or `5` (for MQTT v5.0).
+-- Also you may use special values `mqtt.v311` or `mqtt.v50` for this field.
+-- @tparam[opt] string opts.id MQTT client ID, will be generated by luamqtt library if absent
+-- @tparam[opt=false] boolean|table opts.secure use secure network connection, provided by the lua module set in `opts.ssl_module`.
+-- Set to true to select default parameters, check individual `mqtt.connectors` for supported options.
+-- @tparam[opt] table opts.will will message table with required fields `{ topic="...", payload="..." }`
+-- and optional fields `{ qos=0...2, retain=true/false }`
+-- @tparam[opt=60] number opts.keep_alive time interval (in seconds) for client to send PINGREQ packets to the server when network connection is inactive
+-- @tparam[opt=false] boolean opts.reconnect force created MQTT client to reconnect on connection close.
+-- Set to number value to provide reconnect timeout in seconds.
+-- It's not recommended to use values `< 3`. See also `Client:shutdown`.
+-- @tparam[opt] table opts.connector connector table to open and send/receive packets over network connection.
+-- default is `require("mqtt.connector")` which tries to auto-detect. See `mqtt.connector`.
+-- @tparam[opt="ssl"] string opts.ssl_module module name for the luasec-compatible ssl module, default is `"ssl"`
-- may be used in some non-standard lua environments with own luasec-compatible ssl module
--- @treturn client_mt MQTT client instance table
-function client_mt:__init(args)
+-- @tparam[opt] table opts.on List of event-handlers. See `Client:on` for the format.
+-- @treturn Client MQTT client instance table
+-- @usage
+-- local Client = require "mqtt.client"
+--
+-- local my_client = Client.create {
+-- uri = "mqtts://broker.host.com",
+-- clean = "first",
+-- version = mqtt.v50,
+-- }
+function Client:__init(opts)
if not luamqtt_VERSION then
luamqtt_VERSION = require("mqtt")._VERSION
end
- -- fetch and validate client args
- local a = {} -- own client copy of args
+ -- fetch and validate client opts
+ local a = {} -- own client copy of opts
- for key, value in pairs(args) do
+ for key, value in pairs(opts) do
if type(key) ~= "string" then
- error("expecting string key in args, got: "..type(key))
+ error("expecting string key in opts, got: "..type(key))
end
local value_type = type(value)
@@ -102,7 +104,7 @@ function client_mt:__init(args)
assert(value_type == "string", "expecting uri to be a string")
a.uri = value
elseif key == "clean" then
- assert(value_type == "boolean", "expecting clean to be a boolean")
+ assert(value_type == "boolean" or value == "first", "expecting clean to be a boolean, or 'first'")
a.clean = value
elseif key == "version" then
assert(value_type == "number", "expecting version to be a number")
@@ -140,15 +142,17 @@ function client_mt:__init(args)
elseif key == "ssl_module" then
assert(value_type == "string", "expecting ssl_module to be a string")
a.ssl_module = value
+ elseif key == "on" then
+ assert(value_type == "table", "expecting 'on' to be a table with events and callbacks")
+ a.on = value
else
- error("unexpected key in client args: "..key.." = "..tostring(value))
+ error("unexpected key in client opts: "..key.." = "..tostring(value))
end
end
-- check required arguments
assert(a.uri, 'expecting uri="..." to create MQTT client')
- assert(a.clean ~= nil, "expecting clean=true or clean=false to create MQTT client")
- assert(not a.password or a.username, "password is not accepted in absence of username")
+ assert(a.clean ~= nil, "expecting clean=true, clean=false, or clean='first' to create MQTT client")
if not a.id then
-- generate random client id
@@ -156,19 +160,27 @@ function client_mt:__init(args)
end
-- default connector
- if a.connector == nil then
- if a.secure then
- a.connector = require("mqtt.luasocket_ssl")
- else
- a.connector = require("mqtt.luasocket")
- end
+ a.connector = a.connector or require("mqtt.connector")
+
+ -- default reconnect interval
+ if a.reconnect == true then
+ a.reconnect = 30
end
+
-- validate connector content
assert(type(a.connector) == "table", "expecting connector to be a table")
+ assert(type(a.connector.validate) == "function", "expecting connector.validate to be a function")
assert(type(a.connector.connect) == "function", "expecting connector.connect to be a function")
assert(type(a.connector.shutdown) == "function", "expecting connector.shutdown to be a function")
assert(type(a.connector.send) == "function", "expecting connector.send to be a function")
assert(type(a.connector.receive) == "function", "expecting connector.receive to be a function")
+ assert(a.connector.signal_closed, "missing connector.signal_closed signal value")
+ assert(a.connector.signal_idle, "missing connector.signal_idle signal value")
+
+ -- validate connection properties
+ local test_conn = setmetatable({ uri = opts.uri }, a.connector)
+ Client._parse_connection_opts(a, test_conn)
+ test_conn:validate()
-- will table content check
if a.will then
@@ -188,8 +200,8 @@ function client_mt:__init(args)
a.keep_alive = 60
end
- -- client args
- self.args = a
+ -- client opts
+ self.opts = a
-- event handlers
self.handlers = {
@@ -201,13 +213,15 @@ function client_mt:__init(args)
error = {},
close = {},
auth = {},
+ shutdown = {},
}
self._handling = {}
self._to_remove_handlers = {}
-- state
- self.first_connect = true -- contains true to perform one network connection attemt after client creation
self.send_time = 0 -- time of the last network send from client side
+ self.first_connect = true -- contains true to perform one network connection attempt after client creation
+ -- Note: remains true, during the connect process. False after succes or failure.
-- packet creation/parse functions according version
if not a.version then
@@ -221,17 +235,55 @@ function client_mt:__init(args)
self._parse_packet = parse_packet5
end
- -- automatically add client to default ioloop, if it's available and running, then start connecting
- local loop = ioloop_get(false)
- if loop and loop.running then
- loop:add(self)
- self:start_connecting()
+ -- register event handlers
+ if a.on then
+ self:on(self.opts.on)
end
+
+ log:info("MQTT client '%s' created", a.id)
end
---- Add functions as handlers of given events
--- @param ... (event_name, function) or { event1 = func1, event2 = func2 } table
-function client_mt:on(...)
+--- Add functions as handlers of given events.
+-- @tparam table events MQTT client creation options table
+-- @tparam function events.connect `function(connack_packet, client_obj)`
+-- After a connect attempt, after receiving the CONNACK packet from the broker.
+-- check `connack_packet.rc == 0` for a succesful connect.
+-- @tparam function events.error `function(errmsg, client_obj [, packet])`
+-- on errors, optional `packet` is only provided if the
+-- received `CONNACK.rc ~= 0` when connecting.
+-- @tparam function events.close `function(connection_obj, client_obj)`
+-- upon closing the connection. `connection_obj.close_reason`
+-- (string) will hold the close reason.
+-- @tparam function events.shutdown `function(client_obj)`
+-- upon shutting down the client (diconnecting an no more reconnects).
+-- @tparam function events.subscribe `function(suback_packet, client_obj)`
+-- upon a succesful subscription, after receiving the SUBACK packet from the broker
+-- @tparam function events.unsubscribe `function(unsuback_packet, client_obj)`
+-- upon a succesful unsubscription, after receiving the UNSUBACK packet from the broker
+-- @tparam function events.message `function(publish_packet, client_obj)`
+-- upon receiving a PUBLISH packet from the broker
+-- @tparam function events.acknowledge `function(ack_packet, client_obj)`
+-- upon receiving a PUBACK or PUBREC packet from the broker
+-- @tparam function events.auth `function(auth_packet, client_obj)`
+-- upon receiving an AUTH packet
+-- @usage
+-- client:on {
+-- connect = function(pck, self)
+-- if pck.rc ~= 0 then
+-- return -- connection failed
+-- end
+-- -- succesfully connected
+-- end,
+-- message = function(pck, self)
+-- -- handle received message
+-- end,
+-- }
+--
+-- -- an alternative way to add individual handlers;
+-- client:on("message", function(pck, self)
+-- -- handle received message
+-- end)
+function Client:on(...)
local nargs = select("#", ...)
local events
if nargs == 2 then
@@ -239,7 +291,7 @@ function client_mt:on(...)
elseif nargs == 1 then
events = select(1, ...)
else
- error("invalid args: expected only one or two arguments")
+ error("invalid arguments: expected only one or two arguments")
end
for event, func in pairs(events) do
assert(type(event) == "string", "expecting event to be a string")
@@ -262,10 +314,22 @@ local function remove_item(list, item)
end
end
---- Remove given function handler for specified event
+--- Remove given function handler for specified event.
-- @tparam string event event name to remove handler
-- @tparam function func handler function to remove
-function client_mt:off(event, func)
+-- @usage
+-- local handler = function(pck, self)
+-- -- handle received message
+-- end
+--
+-- -- add event handler
+-- client:on {
+-- message = handler
+-- }
+--
+-- -- remove it again
+-- client:off("message", handler)
+function Client:off(event, func)
local handlers = self.handlers[event]
if not handlers then
error("invalid event '"..tostring(event).."' to handle")
@@ -283,27 +347,27 @@ function client_mt:off(event, func)
end
--- Subscribe to specified topic. Returns the SUBSCRIBE packet id and calls optional callback when subscription will be created on broker
--- @tparam table args subscription arguments
--- @tparam string args.topic topic to subscribe
--- @tparam[opt=0] number args.qos QoS level for subscription
--- @tparam boolean args.no_local for MQTT v5.0 only: no_local flag for subscription
--- @tparam boolean args.retain_as_published for MQTT v5.0 only: retain_as_published flag for subscription
--- @tparam boolean args.retain_handling for MQTT v5.0 only: retain_handling flag for subscription
--- @tparam[opt] table args.properties for MQTT v5.0 only: properties for subscribe operation
--- @tparam[opt] table args.user_properties for MQTT v5.0 only: user properties for subscribe operation
--- @tparam[opt] function args.callback callback function to be called when subscription will be created
+-- @tparam table opts subscription options
+-- @tparam string opts.topic topic to subscribe
+-- @tparam[opt=0] number opts.qos QoS level for subscription
+-- @tparam boolean opts.no_local for MQTT v5.0 only: no_local flag for subscription
+-- @tparam boolean opts.retain_as_published for MQTT v5.0 only: retain_as_published flag for subscription
+-- @tparam boolean opts.retain_handling for MQTT v5.0 only: retain_handling flag for subscription
+-- @tparam[opt] table opts.properties for MQTT v5.0 only: properties for subscribe operation
+-- @tparam[opt] table opts.user_properties for MQTT v5.0 only: user properties for subscribe operation
+-- @tparam[opt] function opts.callback callback function to be called when subscription is acknowledged by broker
-- @return packet id on success or false and error message on failure
-function client_mt:subscribe(args)
- -- fetch and validate args
- assert(type(args) == "table", "expecting args to be a table")
- assert(type(args.topic) == "string", "expecting args.topic to be a string")
- assert(args.qos == nil or (type(args.qos) == "number" and check_qos(args.qos)), "expecting valid args.qos value")
- assert(args.no_local == nil or type(args.no_local) == "boolean", "expecting args.no_local to be a boolean")
- assert(args.retain_as_published == nil or type(args.retain_as_published) == "boolean", "expecting args.retain_as_published to be a boolean")
- assert(args.retain_handling == nil or type(args.retain_handling) == "boolean", "expecting args.retain_handling to be a boolean")
- assert(args.properties == nil or type(args.properties) == "table", "expecting args.properties to be a table")
- assert(args.user_properties == nil or type(args.user_properties) == "table", "expecting args.user_properties to be a table")
- assert(args.callback == nil or type(args.callback) == "function", "expecting args.callback to be a function")
+function Client:subscribe(opts)
+ -- fetch and validate opts
+ assert(type(opts) == "table", "expecting opts to be a table")
+ assert(type(opts.topic) == "string", "expecting opts.topic to be a string")
+ assert(opts.qos == nil or (type(opts.qos) == "number" and check_qos(opts.qos)), "expecting valid opts.qos value")
+ assert(opts.no_local == nil or type(opts.no_local) == "boolean", "expecting opts.no_local to be a boolean")
+ assert(opts.retain_as_published == nil or type(opts.retain_as_published) == "boolean", "expecting opts.retain_as_published to be a boolean")
+ assert(opts.retain_handling == nil or type(opts.retain_handling) == "boolean", "expecting opts.retain_handling to be a boolean")
+ assert(opts.properties == nil or type(opts.properties) == "table", "expecting opts.properties to be a table")
+ assert(opts.user_properties == nil or type(opts.user_properties) == "table", "expecting opts.user_properties to be a table")
+ assert(opts.callback == nil or type(opts.callback) == "function", "expecting opts.callback to be a function")
-- check connection is alive
if not self.connection then
@@ -315,31 +379,34 @@ function client_mt:subscribe(args)
type = packet_type.SUBSCRIBE,
subscriptions = {
{
- topic = args.topic,
- qos = args.qos,
- no_local = args.no_local,
- retain_as_published = args.retain_as_published,
- retain_handling = args.retain_handling
+ topic = opts.topic,
+ qos = opts.qos,
+ no_local = opts.no_local,
+ retain_as_published = opts.retain_as_published,
+ retain_handling = opts.retain_handling
},
},
- properties = args.properties,
- user_properties = args.user_properties,
+ properties = opts.properties,
+ user_properties = opts.user_properties,
}
self:_assign_packet_id(pargs)
local packet_id = pargs.packet_id
local subscribe = self._make_packet(pargs)
+ log:info("subscribing client '%s' to topic '%s' (packet: %s)", self.opts.id, opts.topic, packet_id or "n.a.")
+
-- send SUBSCRIBE packet
local ok, err = self:_send_packet(subscribe)
if not ok then
err = "failed to send SUBSCRIBE: "..err
+ log:error("client '%s': %s", self.opts.id, err)
self:handle("error", err, self)
self:close_connection("error")
return false, err
end
-- add subscribe callback
- local callback = args.callback
+ local callback = opts.callback
if callback then
local function handler(suback, ...)
if suback.packet_id == packet_id then
@@ -355,19 +422,19 @@ function client_mt:subscribe(args)
end
--- Unsubscribe from specified topic, and calls optional callback when subscription will be removed on broker
--- @tparam table args subscription arguments
--- @tparam string args.topic topic to unsubscribe
--- @tparam[opt] table args.properties properties for unsubscribe operation
--- @tparam[opt] table args.user_properties user properties for unsubscribe operation
--- @tparam[opt] function args.callback callback function to be called when subscription will be removed on broker
+-- @tparam table opts subscription options
+-- @tparam string opts.topic topic to unsubscribe
+-- @tparam[opt] table opts.properties properties for unsubscribe operation
+-- @tparam[opt] table opts.user_properties user properties for unsubscribe operation
+-- @tparam[opt] function opts.callback callback function to be called when the unsubscribe is acknowledged by the broker
-- @return packet id on success or false and error message on failure
-function client_mt:unsubscribe(args)
- -- fetch and validate args
- assert(type(args) == "table", "expecting args to be a table")
- assert(type(args.topic) == "string", "expecting args.topic to be a string")
- assert(args.properties == nil or type(args.properties) == "table", "expecting args.properties to be a table")
- assert(args.user_properties == nil or type(args.user_properties) == "table", "expecting args.user_properties to be a table")
- assert(args.callback == nil or type(args.callback) == "function", "expecting args.callback to be a function")
+function Client:unsubscribe(opts)
+ -- fetch and validate opts
+ assert(type(opts) == "table", "expecting opts to be a table")
+ assert(type(opts.topic) == "string", "expecting opts.topic to be a string")
+ assert(opts.properties == nil or type(opts.properties) == "table", "expecting opts.properties to be a table")
+ assert(opts.user_properties == nil or type(opts.user_properties) == "table", "expecting opts.user_properties to be a table")
+ assert(opts.callback == nil or type(opts.callback) == "function", "expecting opts.callback to be a function")
-- check connection is alive
@@ -378,25 +445,28 @@ function client_mt:unsubscribe(args)
-- create UNSUBSCRIBE packet
local pargs = {
type = packet_type.UNSUBSCRIBE,
- subscriptions = {args.topic},
- properties = args.properties,
- user_properties = args.user_properties,
+ subscriptions = {opts.topic},
+ properties = opts.properties,
+ user_properties = opts.user_properties,
}
self:_assign_packet_id(pargs)
local packet_id = pargs.packet_id
local unsubscribe = self._make_packet(pargs)
+ log:info("unsubscribing client '%s' from topic '%s' (packet: %s)", self.opts.id, opts.topic, packet_id or "n.a.")
+
-- send UNSUBSCRIBE packet
local ok, err = self:_send_packet(unsubscribe)
if not ok then
err = "failed to send UNSUBSCRIBE: "..err
+ log:error("client '%s' %s", self.opts.id, err)
self:handle("error", err, self)
self:close_connection("error")
return false, err
end
-- add unsubscribe callback
- local callback = args.callback
+ local callback = opts.callback
if callback then
local function handler(unsuback, ...)
if unsuback.packet_id == packet_id then
@@ -412,30 +482,30 @@ function client_mt:unsubscribe(args)
end
--- Publish message to broker
--- @tparam table args publish operation arguments table
--- @tparam string args.topic topic to publish message
--- @tparam[opt] string args.payload publish message payload
--- @tparam[opt=0] number args.qos QoS level for message publication
--- @tparam[opt=false] boolean args.retain retain message publication flag
--- @tparam[opt=false] boolean args.dup dup message publication flag
--- @tparam[opt] table args.properties properties for publishing message
--- @tparam[opt] table args.user_properties user properties for publishing message
--- @tparam[opt] function args.callback callback to call when publihsed message will be acknowledged
+-- @tparam table opts publish operation options table
+-- @tparam string opts.topic topic to publish message
+-- @tparam[opt] string opts.payload publish message payload
+-- @tparam[opt=0] number opts.qos QoS level for message publication
+-- @tparam[opt=false] boolean opts.retain retain message publication flag
+-- @tparam[opt=false] boolean opts.dup dup message publication flag
+-- @tparam[opt] table opts.properties properties for publishing message
+-- @tparam[opt] table opts.user_properties user properties for publishing message
+-- @tparam[opt] function opts.callback callback to call when published message has been acknowledged by the broker
-- @return true or packet id on success or false and error message on failure
-function client_mt:publish(args)
- -- fetch and validate args
- assert(type(args) == "table", "expecting args to be a table")
- assert(type(args.topic) == "string", "expecting args.topic to be a string")
- assert(args.payload == nil or type(args.payload) == "string", "expecting args.payload to be a string")
- assert(args.qos == nil or type(args.qos) == "number", "expecting args.qos to be a number")
- if args.qos then
- assert(check_qos(args.qos), "expecting qos to be a valid QoS value")
+function Client:publish(opts)
+ -- fetch and validate opts
+ assert(type(opts) == "table", "expecting opts to be a table")
+ assert(type(opts.topic) == "string", "expecting opts.topic to be a string")
+ assert(opts.payload == nil or type(opts.payload) == "string", "expecting opts.payload to be a string")
+ assert(opts.qos == nil or type(opts.qos) == "number", "expecting opts.qos to be a number")
+ if opts.qos then
+ assert(check_qos(opts.qos), "expecting qos to be a valid QoS value")
end
- assert(args.retain == nil or type(args.retain) == "boolean", "expecting args.retain to be a boolean")
- assert(args.dup == nil or type(args.dup) == "boolean", "expecting args.dup to be a boolean")
- assert(args.properties == nil or type(args.properties) == "table", "expecting args.properties to be a table")
- assert(args.user_properties == nil or type(args.user_properties) == "table", "expecting args.user_properties to be a table")
- assert(args.callback == nil or type(args.callback) == "function", "expecting args.callback to be a function")
+ assert(opts.retain == nil or type(opts.retain) == "boolean", "expecting opts.retain to be a boolean")
+ assert(opts.dup == nil or type(opts.dup) == "boolean", "expecting opts.dup to be a boolean")
+ assert(opts.properties == nil or type(opts.properties) == "table", "expecting opts.properties to be a table")
+ assert(opts.user_properties == nil or type(opts.user_properties) == "table", "expecting opts.user_properties to be a table")
+ assert(opts.callback == nil or type(opts.callback) == "function", "expecting opts.callback to be a function")
-- check connection is alive
local conn = self.connection
@@ -444,27 +514,30 @@ function client_mt:publish(args)
end
-- create PUBLISH packet
- args.type = packet_type.PUBLISH
- self:_assign_packet_id(args)
- local packet_id = args.packet_id
- local publish = self._make_packet(args)
+ opts.type = packet_type.PUBLISH
+ self:_assign_packet_id(opts)
+ local packet_id = opts.packet_id
+ local publish = self._make_packet(opts)
+
+ log:debug("client '%s' publishing to topic '%s' (packet: %s)", self.opts.id, opts.topic, packet_id or "n.a.")
-- send PUBLISH packet
local ok, err = self:_send_packet(publish)
if not ok then
err = "failed to send PUBLISH: "..err
+ log:error("client '%s' %s", self.opts.id, err)
self:handle("error", err, self)
self:close_connection("error")
return false, err
end
-- record packet id as waited for QoS 2 exchange
- if args.qos == 2 then
+ if opts.qos == 2 then
conn.wait_for_pubrec[packet_id] = true
end
-- add acknowledge callback
- local callback = args.callback
+ local callback = opts.callback
if callback then
if packet_id then
local function handler(ack, ...)
@@ -489,7 +562,7 @@ end
-- @tparam[opt] table properties properties for PUBACK/PUBREC packets
-- @tparam[opt] table user_properties user properties for PUBACK/PUBREC packets
-- @return true on success or false and error message on failure
-function client_mt:acknowledge(msg, rc, properties, user_properties)
+function Client:acknowledge(msg, rc, properties, user_properties)
assert(type(msg) == "table" and msg.type == packet_type.PUBLISH, "expecting msg to be a publish packet")
assert(rc == nil or type(rc) == "number", "expecting rc to be a number")
assert(properties == nil or type(properties) == "table", "expecting properties to be a table")
@@ -507,6 +580,8 @@ function client_mt:acknowledge(msg, rc, properties, user_properties)
return true
end
+ log:debug("client '%s' acknowledging packet %s", self.opts.id, packet_id or "n.a.")
+
if msg.qos == 1 then
-- PUBACK should be sent
@@ -523,6 +598,7 @@ function client_mt:acknowledge(msg, rc, properties, user_properties)
local ok, err = self:_send_packet(puback)
if not ok then
err = "failed to send PUBACK: "..err
+ log:error("client '%s' %s", self.opts.id, err)
self:handle("error", err, self)
self:close_connection("error")
return false, err
@@ -543,6 +619,7 @@ function client_mt:acknowledge(msg, rc, properties, user_properties)
local ok, err = self:_send_packet(pubrec)
if not ok then
err = "failed to send PUBREC: "..err
+ log:error("client '%s' %s", self.opts.id, err)
self:handle("error", err, self)
self:close_connection("error")
return false, err
@@ -555,13 +632,15 @@ function client_mt:acknowledge(msg, rc, properties, user_properties)
return true
end
---- Send DISCONNECT packet to the broker and close the connection
+--- Send DISCONNECT packet to the broker and close the connection.
+-- Note: if the client is set to automatically reconnect, it will do so. If you
+-- want to disconnect and NOT reconnect, use `Client:shutdown`.
-- @tparam[opt=0] number rc The Disconnect Reason Code value from MQTT v5.0 protocol
-- @tparam[opt] table properties properties for PUBACK/PUBREC packets
-- @tparam[opt] table user_properties user properties for PUBACK/PUBREC packets
-- @return true on success or false and error message on failure
-function client_mt:disconnect(rc, properties, user_properties)
- -- validate args
+function Client:disconnect(rc, properties, user_properties)
+ -- validate opts
assert(rc == nil or type(rc) == "number", "expecting rc to be a number")
assert(properties == nil or type(properties) == "table", "expecting properties to be a table")
assert(user_properties == nil or type(user_properties) == "table", "expecting user_properties to be a table")
@@ -579,10 +658,13 @@ function client_mt:disconnect(rc, properties, user_properties)
user_properties = user_properties,
}
+ log:info("client '%s' disconnecting (rc = %d)", self.opts.id, rc or 0)
+
-- send DISCONNECT packet
local ok, err = self:_send_packet(disconnect)
if not ok then
err = "failed to send DISCONNECT: "..err
+ log:error("client '%s' %s", self.opts.id, err)
self:handle("error", err, self)
self:close_connection("error")
return false, err
@@ -594,17 +676,32 @@ function client_mt:disconnect(rc, properties, user_properties)
return true
end
+--- Shuts the client down.
+-- Disconnects if still connected, and disables reconnecting. If the client is
+-- added to an ioloop, this will prevent an automatic reconnect.
+-- Raises the "shutdown" event.
+-- @param ... see `Client:disconnect`
+-- @return `true`
+function Client:shutdown(...)
+ log:debug("client '%s' shutting down", self.opts.id)
+ self.first_connect = false
+ self.opts.reconnect = false
+ self:disconnect(...)
+ self:handle("shutdown", self)
+ return true
+end
+
--- Send AUTH packet to authenticate client on broker, in MQTT v5.0 protocol
-- @tparam[opt=0] number rc Authenticate Reason Code
-- @tparam[opt] table properties properties for PUBACK/PUBREC packets
-- @tparam[opt] table user_properties user properties for PUBACK/PUBREC packets
-- @return true on success or false and error message on failure
-function client_mt:auth(rc, properties, user_properties)
- -- validate args
+function Client:auth(rc, properties, user_properties)
+ -- validate opts
assert(rc == nil or type(rc) == "number", "expecting rc to be a number")
assert(properties == nil or type(properties) == "table", "expecting properties to be a table")
assert(user_properties == nil or type(user_properties) == "table", "expecting user_properties to be a table")
- assert(self.args.version == 5, "allowed only in MQTT v5.0 protocol")
+ assert(self.opts.version == 5, "allowed only in MQTT v5.0 protocol")
-- check connection is alive
if not self.connection then
@@ -619,10 +716,13 @@ function client_mt:auth(rc, properties, user_properties)
user_properties = user_properties,
}
+ log:info("client '%s' authenticating")
+
-- send AUTH packet
local ok, err = self:_send_packet(auth)
if not ok then
err = "failed to send AUTH: "..err
+ log:error("client '%s' %s", self.opts.id, err)
self:handle("error", err, self)
self:close_connection("error")
return false, err
@@ -633,44 +733,39 @@ end
--- Immediately close established network connection, without graceful session finishing with DISCONNECT packet
-- @tparam[opt] string reason the reasong string of connection close
-function client_mt:close_connection(reason)
+function Client:close_connection(reason)
assert(not reason or type(reason) == "string", "expecting reason to be a string")
local conn = self.connection
if not conn then
return true
end
- local args = self.args
- args.connector.shutdown(conn)
+ reason = reason or "unspecified"
+
+ log:info("client '%s' closing connection (reason: %s)", self.opts.id, reason)
+
+ conn:shutdown()
self.connection = nil
- conn.close_reason = reason or "unspecified"
+ conn.close_reason = reason
self:handle("close", conn, self)
-
- -- check connection is still closed (self.connection may be re-created in "close" handler)
- if not self.connection then
- -- remove from ioloop
- if self.ioloop and not args.reconnect then
- self.ioloop:remove(self)
- end
- end
-
return true
end
--- Start connecting to broker
-- @return true on success or false and error message on failure
-function client_mt:start_connecting()
- -- print("start connecting") -- debug
+function Client:start_connecting()
-- open network connection
local ok, err = self:open_connection()
if not ok then
+ self.first_connect = false
return false, err
end
-- send CONNECT packet
ok, err = self:send_connect()
if not ok then
+ self.first_connect = false
return false, err
end
@@ -682,7 +777,7 @@ end
--- Send PINGREQ packet
-- @return true on success or false and error message on failure
-function client_mt:send_pingreq()
+function Client:send_pingreq()
-- check connection is alive
if not self.connection then
return false, "network connection is not opened"
@@ -693,40 +788,48 @@ function client_mt:send_pingreq()
type = packet_type.PINGREQ,
}
+ log:debug("client '%s' sending PINGREQ", self.opts.id)
+
-- send PINGREQ packet
local ok, err = self:_send_packet(pingreq)
if not ok then
err = "failed to send PINGREQ: "..err
+ log:error("client '%s' %s", self.opts.id, err)
self:handle("error", err, self)
self:close_connection("error")
return false, err
end
+ -- set ping timeout; for now 1 ping-request interval
+ self.ping_expire_time = os_time() + self.opts.keep_alive
+
return true
end
--- Open network connection to the broker
-- @return true on success or false and error message on failure
-function client_mt:open_connection()
+function Client:open_connection()
if self.connection then
return true
end
- local args = self.args
- local connector = assert(args.connector, "no connector configured in MQTT client")
+ local opts = self.opts
+ local connector = assert(opts.connector, "no connector configured in MQTT client")
-- create connection table
- local conn = {
- uri = args.uri,
- wait_for_pubrec = {}, -- a table with packet_id of parially acknowledged sent packets in QoS 2 exchange process
- wait_for_pubrel = {}, -- a table with packet_id of parially acknowledged received packets in QoS 2 exchange process
- }
- client_mt._parse_uri(args, conn)
- client_mt._apply_secure(args, conn)
+ local conn = setmetatable({
+ uri = opts.uri,
+ wait_for_pubrec = {}, -- a table with packet_id of partially acknowledged sent packets in QoS 2 exchange process
+ wait_for_pubrel = {}, -- a table with packet_id of partially acknowledged received packets in QoS 2 exchange process
+ }, connector)
+ Client._parse_connection_opts(opts, conn)
+
+ log:info("client '%s' connecting to broker '%s' (using: %s)", self.opts.id, opts.uri, conn.type or "unknown")
-- perform connect
- local ok, err = connector.connect(conn)
+ local ok, err = conn:connect()
if not ok then
+ log:error("client '%s' %s", self.opts.id, err)
err = "failed to open network connection: "..err
self:handle("error", err, self)
return false, err
@@ -735,44 +838,42 @@ function client_mt:open_connection()
-- assign connection
self.connection = conn
- -- create receive function
- local receive = connector.receive
- self.connection.recv_func = function(size)
- return receive(conn, size)
- end
-
- self:_apply_network_timeout()
+ -- reset ping timeout
+ self.ping_expire_time = nil
return true
end
--- Send CONNECT packet into opened network connection
-- @return true on success or false and error message on failure
-function client_mt:send_connect()
+function Client:send_connect()
-- check connection is alive
if not self.connection then
return false, "network connection is not opened"
end
- local args = self.args
+ local opts = self.opts
-- create CONNECT packet
local connect = self._make_packet{
type = packet_type.CONNECT,
- id = args.id,
- clean = args.clean,
- username = args.username,
- password = args.password,
- will = args.will,
- keep_alive = args.keep_alive,
- properties = args.properties,
- user_properties = args.user_properties,
+ id = opts.id,
+ clean = not not opts.clean, -- force to boolean, in case "first"
+ username = opts.username,
+ password = opts.password,
+ will = opts.will,
+ keep_alive = opts.keep_alive,
+ properties = opts.properties,
+ user_properties = opts.user_properties,
}
+ log:info("client '%s' sending CONNECT (user '%s')", self.opts.id, opts.username or "not specified")
+
-- send CONNECT packet
local ok, err = self:_send_packet(connect)
if not ok then
err = "failed to send CONNECT: "..err
+ log:error("client '%s' %s", self.opts.id, err)
self:handle("error", err, self)
self:close_connection("error")
return false, err
@@ -784,17 +885,61 @@ function client_mt:send_connect()
return true
end
--- Internal methods
+--- Checks last message send, and sends a PINGREQ if necessary.
+-- Use this function to check and send keep-alives when using an external event loop. When using the
+-- included modules to add clients (see `mqtt.loop`), this will be taken care of automatically.
+-- @treturn[1] number time till next keep_alive (in seconds)
+-- @treturn[2] number time till next keep_alive (in seconds)
+-- @treturn[2] string in case of errors (eg. not connected) the second return value is an error string
+-- @usage
+-- -- example using a Copas event loop to send and check keep-alives
+-- copas.addthread(function()
+-- while true do
+-- if not my_client then
+-- return -- exiting, client was destroyed
+-- end
+-- copas.pause(my_client:check_keep_alive())
+-- end
+-- end)
+function Client:check_keep_alive()
+ local interval = self.opts.keep_alive
+ if not self.connection then
+ return interval, "network connection is not opened"
+ end
--- Set or rest ioloop for MQTT client
-function client_mt:set_ioloop(loop)
- self.ioloop = loop
- self:_apply_network_timeout()
+ local t_now = os_time()
+ local t_next = self.send_time + interval
+ local t_timeout = self.ping_expire_time
+
+ -- check last ping request
+ if t_timeout and t_timeout <= t_now then
+ -- we timed-out, close and exit
+ local err = str_format("failed to receive PINGRESP within %d seconds", interval)
+ log:error("client '%s' %s", self.opts.id, err)
+ self:handle("error", err, self)
+ self:close_connection("error")
+ return interval, err
+ end
+
+ -- send PINGREQ if keep_alive interval is reached
+ if t_now >= t_next then
+ local _, err = self:send_pingreq()
+ return interval, err
+ end
+
+ -- return which ever is earlier, timeout or next ping request
+ if t_timeout and t_timeout < t_next then
+ return t_timeout - t_now
+ end
+ return t_next - t_now
end
+
+-- Internal methods
+
-- Send PUBREL acknowledge packet - second phase of QoS 2 exchange
-- Returns true on success or false and error message on failure
-function client_mt:acknowledge_pubrel(packet_id)
+function Client:acknowledge_pubrel(packet_id)
-- check connection is alive
if not self.connection then
return false, "network connection is not opened"
@@ -803,10 +948,13 @@ function client_mt:acknowledge_pubrel(packet_id)
-- create PUBREL packet
local pubrel = self._make_packet{type=packet_type.PUBREL, packet_id=packet_id, rc=0}
+ log:debug("client '%s' sending PUBREL (packet: %s)", self.opts.id, packet_id or "n.a.")
+
-- send PUBREL packet
local ok, err = self:_send_packet(pubrel)
if not ok then
err = "failed to send PUBREL: "..err
+ log:error("client '%s' %s", self.opts.id, err)
self:handle("error", err, self)
self:close_connection("error")
return false, err
@@ -817,7 +965,7 @@ end
-- Send PUBCOMP acknowledge packet - last phase of QoS 2 exchange
-- Returns true on success or false and error message on failure
-function client_mt:acknowledge_pubcomp(packet_id)
+function Client:acknowledge_pubcomp(packet_id)
-- check connection is alive
if not self.connection then
return false, "network connection is not opened"
@@ -826,10 +974,13 @@ function client_mt:acknowledge_pubcomp(packet_id)
-- create PUBCOMP packet
local pubcomp = self._make_packet{type=packet_type.PUBCOMP, packet_id=packet_id, rc=0}
+ log:debug("client '%s' sending PUBCOMP (packet: %s)", self.opts.id, packet_id or "n.a.")
+
-- send PUBCOMP packet
local ok, err = self:_send_packet(pubcomp)
if not ok then
err = "failed to send PUBCOMP: "..err
+ log:error("client '%s' %s", self.opts.id, err)
self:handle("error", err, self)
self:close_connection("error")
return false, err
@@ -839,18 +990,18 @@ function client_mt:acknowledge_pubcomp(packet_id)
end
-- Call specified event handlers
-function client_mt:handle(event, ...)
+function Client:handle(event, ...)
local handlers = self.handlers[event]
if not handlers then
error("invalid event '"..tostring(event).."' to handle")
end
- self._handling[event] = true -- protecting self.handlers[event] table from modifications by client_mt:off() when iterating
+ self._handling[event] = true -- protecting self.handlers[event] table from modifications by Client:off() when iterating
for _, handler in ipairs(handlers) do
handler(...)
end
self._handling[event] = nil
- -- process handlers removing, scheduled by client_mt:off()
+ -- process handlers removing, scheduled by Client:off()
local to_remove = self._to_remove_handlers[event]
if to_remove then
for _, func in ipairs(to_remove) do
@@ -862,8 +1013,8 @@ end
-- Internal methods
--- Assign next packet id for given packet creation args
-function client_mt:_assign_packet_id(pargs)
+-- Assign next packet id for given packet creation opts
+function Client:_assign_packet_id(pargs)
if not pargs.packet_id then
if packet_id_required(pargs) then
self._last_packet_id = next_packet_id(self._last_packet_id)
@@ -872,282 +1023,288 @@ function client_mt:_assign_packet_id(pargs)
end
end
--- Receive packet function in sync mode
-local function sync_recv(self)
- return true, self:_receive_packet()
-end
-
--- Perform one input/output iteration, called by sync receiving loop
-function client_mt:_sync_iteration()
- return self:_io_iteration(sync_recv)
-end
-
--- Receive packet function - from ioloop's coroutine
-local function ioloop_recv(self)
- return coroutine_resume(self.connection.coro)
-end
-
--- Perform one input/output iteration, called by ioloop
-function client_mt:_ioloop_iteration()
- -- working according state
- local loop = self.ioloop
- local args = self.args
-
+-- Handle a single received packet
+function Client:handle_received_packet(packet)
local conn = self.connection
- if conn then
- -- network connection opened
- -- perform packet receiving using ioloop receive function
- local ok, err
- if loop then
- ok, err = self:_io_iteration(ioloop_recv)
- else
- ok, err = self:_sync_iteration()
- end
+ local err
- if ok then
- -- send PINGREQ if keep_alive interval is reached
- if os_time() - self.send_time >= args.keep_alive then
- self:send_pingreq()
- end
- end
+ log:debug("client '%s' received '%s' (packet: %s)", self.opts.id, packet_type[packet.type], packet.packet_id or "n.a.")
- return ok, err
- else
- -- no connection - first connect, reconnect or remove from ioloop
- if self.first_connect then
- self.first_connect = false
- self:start_connecting()
- elseif args.reconnect then
- if args.reconnect == true then
- self:start_connecting()
- else
- -- reconnect in specified timeout
- if self.reconnect_timer_start then
- if os_time() - self.reconnect_timer_start >= args.reconnect then
- self.reconnect_timer_start = nil
- self:start_connecting()
- else
- if loop then
- loop:can_sleep()
- end
- end
- else
- self.reconnect_timer_start = os_time()
- end
- end
- else
- -- finish working with client
- if loop then
- loop:remove(self)
- end
- end
- end
-end
-
--- Performing one IO iteration - receive next packet
-function client_mt:_io_iteration(recv)
- local conn = self.connection
-
- -- first - try to receive packet
- local ok, packet, err = recv(self)
- -- print("received packet", ok, packet, err)
-
- -- check coroutine resume status
- if not ok then
- err = "failed to resume receive packet coroutine: "..tostring(packet)
- self:handle("error", err, self)
- self:close_connection("error")
- return false, err
- end
-
- -- check for communication error
- if packet == false then
- if err == "closed" then
- self:close_connection("connection closed by broker")
- return false, err
- else
- err = "failed to receive next packet: "..err
+ if not conn.connack then
+ -- expecting only CONNACK packet here
+ if packet.type ~= packet_type.CONNACK then
+ err = "expecting CONNACK but received "..packet.type
+ log:error("client '%s' %s", self.opts.id, err)
self:handle("error", err, self)
self:close_connection("error")
+ self.first_connect = false
return false, err
end
- end
- -- check some packet received
- if packet ~= "timeout" and packet ~= "wantread" then
- if not conn.connack then
- -- expecting only CONNACK packet here
- if packet.type ~= packet_type.CONNACK then
- err = "expecting CONNACK but received "..packet.type
- self:handle("error", err, self)
- self:close_connection("error")
- return false, err
- end
+ -- store connack packet in connection
+ conn.connack = packet
- -- store connack packet in connection
- conn.connack = packet
-
- -- check CONNACK rc
- if packet.rc ~= 0 then
- err = str_format("CONNECT failed with CONNACK [rc=%d]: %s", packet.rc, packet:reason_string())
- self:handle("error", err, self, packet)
- self:handle("connect", packet, self)
- self:close_connection("connection failed")
- return false, err
- end
-
- -- fire connect event
+ -- check CONNACK rc
+ if packet.rc ~= 0 then
+ err = str_format("CONNECT failed with CONNACK [rc=%d]: %s", packet.rc, packet:reason_string())
+ log:error("client '%s' %s", self.opts.id, err)
+ self:handle("error", err, self, packet)
self:handle("connect", packet, self)
- else
- -- connection authorized, so process usual packets
+ self:close_connection("connection failed")
+ self.first_connect = false
+ return false, err
+ end
- -- handle packet according its type
- local ptype = packet.type
- if ptype == packet_type.PINGRESP then -- luacheck: ignore
- -- PINGREQ answer, nothing to do
- -- TODO: break the connectin in absence of this packet in some timeout
- elseif ptype == packet_type.SUBACK then
- self:handle("subscribe", packet, self)
- elseif ptype == packet_type.UNSUBACK then
- self:handle("unsubscribe", packet, self)
- elseif ptype == packet_type.PUBLISH then
- -- check such packet is not waiting for pubrel acknowledge
- self:handle("message", packet, self)
- elseif ptype == packet_type.PUBACK then
- self:handle("acknowledge", packet, self)
- elseif ptype == packet_type.PUBREC then
- local packet_id = packet.packet_id
- if conn.wait_for_pubrec[packet_id] then
- conn.wait_for_pubrec[packet_id] = nil
- -- send PUBREL acknowledge
- if self:acknowledge_pubrel(packet_id) then
- -- and fire acknowledge event
- self:handle("acknowledge", packet, self)
- end
+ log:info("client '%s' connected successfully to '%s:%s'", self.opts.id, conn.host, conn.port)
+
+ -- fire connect event
+ if self.opts.clean == "first" then
+ self.opts.clean = false -- reset clean flag to false, so next connection resumes previous session
+ log:debug("client '%s'; switching clean flag to false (was 'first')", self.opts.id)
+ end
+ self:handle("connect", packet, self)
+ self.first_connect = false
+ else
+ -- connection authorized, so process usual packets
+
+ -- handle packet according its type
+ local ptype = packet.type
+ if ptype == packet_type.PINGRESP then -- luacheck: ignore
+ -- PINGREQ answer, clear timeout
+ self.ping_expire_time = nil
+ elseif ptype == packet_type.SUBACK then
+ self:handle("subscribe", packet, self)
+ elseif ptype == packet_type.UNSUBACK then
+ self:handle("unsubscribe", packet, self)
+ elseif ptype == packet_type.PUBLISH then
+ -- check such packet is not waiting for pubrel acknowledge
+ self:handle("message", packet, self)
+ elseif ptype == packet_type.PUBACK then
+ self:handle("acknowledge", packet, self)
+ elseif ptype == packet_type.PUBREC then
+ local packet_id = packet.packet_id
+ if conn.wait_for_pubrec[packet_id] then
+ conn.wait_for_pubrec[packet_id] = nil
+ -- send PUBREL acknowledge
+ if self:acknowledge_pubrel(packet_id) then
+ -- and fire acknowledge event
+ self:handle("acknowledge", packet, self)
end
- elseif ptype == packet_type.PUBREL then
- -- second phase of QoS 2 exchange - check we are already acknowledged such packet by PUBREL
- local packet_id = packet.packet_id
- if conn.wait_for_pubrel[packet_id] then
- -- remove packet from waiting for PUBREL packets table
- conn.wait_for_pubrel[packet_id] = nil
- -- send PUBCOMP acknowledge
- self:acknowledge_pubcomp(packet_id)
- end
- elseif ptype == packet_type.PUBCOMP then --luacheck: ignore
- -- last phase of QoS 2 exchange
- -- do nothing here
- elseif ptype == packet_type.DISCONNECT then
- self:close_connection("disconnect received from broker")
- elseif ptype == packet_type.AUTH then
- self:handle("auth", packet, self)
- -- else
- -- print("unhandled packet:", packet) -- debug
end
+ elseif ptype == packet_type.PUBREL then
+ -- second phase of QoS 2 exchange - check we are already acknowledged such packet by PUBREL
+ local packet_id = packet.packet_id
+ if conn.wait_for_pubrel[packet_id] then
+ -- remove packet from waiting for PUBREL packets table
+ conn.wait_for_pubrel[packet_id] = nil
+ -- send PUBCOMP acknowledge
+ return self:acknowledge_pubcomp(packet_id)
+ end
+ elseif ptype == packet_type.PUBCOMP then --luacheck: ignore
+ -- last phase of QoS 2 exchange
+ -- do nothing here
+ elseif ptype == packet_type.DISCONNECT then
+ self:close_connection("disconnect received from broker")
+ elseif ptype == packet_type.AUTH then
+ self:handle("auth", packet, self)
+ else
+ log:warn("client '%s' don't know how to handle %s", self.opts.id, ptype)
end
end
-
return true
end
--- Apply ioloop network timeout to already established connection (if any)
-function client_mt:_apply_network_timeout()
- local conn = self.connection
- if conn then
- local loop = self.ioloop
- if loop then
- -- apply connection timeout
- self.args.connector.settimeout(conn, loop.args.timeout)
+do
+ -- implict (re)connecting when reading
+ local function implicit_connect(self)
+ local reconnect = self.opts.reconnect
- -- connection packets receive loop coroutine
- conn.coro = coroutine_create(function()
- while true do
- local packet, err = self:_receive_packet()
- if not packet then
- return false, err
- else
- coroutine_yield(packet)
- end
- end
- end)
+ if not self.first_connect and not reconnect then
+ -- this would be a re-connect, but we're not supposed to auto-reconnect
+ log:debug("client '%s' was disconnected and not set to auto-reconnect", self.opts.id)
+ return false, "network connection is not opened"
+ end
- -- replace connection recv_func with coroutine-based version
- local sync_recv_func = conn.recv_func
- conn.recv_func = function(totalSize, ...)
- while true do
- local allData = ""
- while true do
- local size = math.min(totalSize, 16384)
- local data, err = sync_recv_func(size, ...)
- if not data and (err == "timeout" or err == "wantread") then
- loop.timeouted = true
- coroutine_yield(err)
- elseif data then
- allData = allData .. data
- totalSize = totalSize - size
- if totalSize == 0 then
- return allData, nil
- end
- else
- return nil, err
- end
- end
- end
- end
- conn.sync_recv_func = sync_recv_func
- else
- -- disable connection timeout
- self.args.connector.settimeout(conn, nil)
+ -- should we wait for a timeout between retries?
+ local t_reconnect = (self.last_connect_time or 0) + (reconnect or 0)
+ local t_now = os_time()
+ if t_reconnect > t_now then
+ -- were delaying before retrying, return remaining delay
+ return t_reconnect - t_now
+ end
- -- replace back usual (blocking) connection recv_func
- if conn.sync_recv_func then
- conn.recv_func = conn.sync_recv_func
- conn.sync_recv_func = nil
+ self.last_connect_time = t_now
+
+ local ok, err = self:start_connecting()
+ if not ok then
+ -- we failed to connect
+ return reconnect, err
+ end
+
+ -- connected succesfully, but don't know how long it took, so return now
+ -- to be nice to other clients. Return 0 to indicate ready-for-reading.
+ return 0
+ end
+
+ --- Performs a single IO loop step.
+ -- It will connect if not connected, will re-connect if set to.
+ -- This should be called repeatedly in a loop. When using the included modules to
+ -- add clients (see `mqtt.loop`), this will be taken care of automatically.
+ --
+ -- The return value is the time after which this method must be called again.
+ -- It can be called sooner, but shouldn't be called later.
+ -- @return[1] `-1`: the socket read timed out, so it is idle. This return code is only
+ -- returned with buffered connectors (luasocket), never for yielding sockets
+ -- (Copas or OpenResty)
+ -- @return[2] `0`: a packet was succesfully handled, so retry immediately, no delays,
+ -- in case additional data is waiting to be read on the socket.
+ -- @return[3] `>0`: The reconnect timer needs a delay before it can retry (calling
+ -- sooner is not a problem, it will only reconnect when the delay
+ -- has actually passed)
+ -- @return[4] nil
+ -- @return[4] error message
+ function Client:step()
+ local conn = self.connection
+ local reconnect = self.opts.reconnect
+
+ -- try and connect if not connected yet
+ if not conn then
+ return implicit_connect(self)
+ end
+
+ local packet, err = self:_receive_packet()
+ if not packet then
+ if err == conn.signal_idle then
+ -- connection was idle, nothing happened
+ return -1
+ elseif err == conn.signal_closed then
+ self:close_connection("connection closed by broker")
+ return reconnect and 0, err
+ else
+ err = "failed to receive next packet: "..tostring(err)
+ log:error("client '%s' %s", self.opts.id, err)
+ self:handle("error", err, self)
+ self:close_connection("error")
+ return reconnect and 0, err
end
end
+
+ local ok
+ ok, err = self:handle_received_packet(packet)
+ if not ok then
+ return reconnect and 0, err
+ end
+
+ -- succesfully handled packed, maybe there is more, so retry asap
+ return 0
end
end
--- Fill given connection table with host and port according given args
-function client_mt._parse_uri(args, conn)
- local host, port = str_match(args.uri, "^([^%s]+):(%d+)$")
- if not host then
- -- trying pattern without port
- host = assert(str_match(conn.uri, "^([^%s]+)$"), "invalid uri format: expecting at least host/ip in .uri")
- end
- if not port then
- if args.secure then
- port = 8883 -- default MQTT secure connection port
- else
- port = 1883 -- default MQTT connection port
+-- Fill given connection table with host and port according given opts
+-- uri: mqtt[s]://[username][:password]@host.domain[:port]
+function Client._parse_connection_opts(opts, conn)
+ local uri = assert(conn.uri)
+
+ -- protocol
+ local uriprotocol = uri:match("^([%a%d%-]-)://")
+ if uriprotocol then
+ uriprotocol = uriprotocol:lower()
+ uri = uri:gsub("^[%a%d%-]-://","")
+
+ if uriprotocol == "mqtts" and opts.secure == nil then
+ opts.secure = true -- NOTE: goes into client 'opts' table, not in 'conn'
+
+ elseif uriprotocol == "mqtt" and opts.secure == nil then
+ opts.secure = false -- NOTE: goes into client 'opts' table, not in 'conn'
+
+ elseif uriprotocol == "mqtts" and opts.secure == false then
+ error("cannot use protocol 'mqtts' with 'secure=false'")
+
+ elseif uriprotocol == "mqtt" and opts.secure then
+ error("cannot use protocol 'mqtt' with 'secure=true|table'")
end
else
- port = tonumber(port)
+ -- no protocol info found in uri
+ if opts.secure then
+ uriprotocol = "mqtts"
+ else
+ uriprotocol = "mqtt"
+ end
end
- conn.host, conn.port = host, port
-end
--- Creates the conn.secure_params table and its content according client creation args
-function client_mt._apply_secure(args, conn)
- local secure = args.secure
+ conn.protocol = opts.protocol or uriprotocol
+ assert(type(conn.protocol) == "string", "expected protocol to be a string")
+ assert(conn.protocol:match("mqtts?"), "only 'mqtt(s)' protocol is supported in the uri, got '"..tostring(conn.protocol).."'")
+ -- print("protocol: ", uriprotocol)
+
+ -- creds, host/port
+ local creds, host_port
+ if uri:find("@") then
+ host_port = uri:match("@(.-)$"):lower()
+ creds = uri:gsub("@.-$", "")
+ else
+ host_port = uri
+ end
+ -- print("creds: ", creds)
+ -- print("host_port:", host_port)
+
+ -- host-port
+ local host, port = host_port:match("^([^:]+):?([^:]*)$")
+ if port and #port > 0 then
+ port = assert(tonumber(port), "port in uri must be numeric, got: '"..port.."'")
+ else
+ port = nil
+ end
+ -- print("port: ", port)
+ -- print("host: ", host)
+ conn.host = opts.host or host
+ assert(type(conn.host) == "string", "expected host to be a string")
+ -- default port
+ conn.port = opts.port or port
+ if not conn.port then
+ if opts.secure then
+ conn.port = 8883 -- default MQTT secure connection port
+ else
+ conn.port = 1883 -- default MQTT connection port
+ end
+ end
+ assert(type(conn.port) == "number", "expected port to be a number")
+
+
+ -- username-password
+ local username, password
+ if creds then
+ username, password = creds:match("^([^:]+):?([^:]*)$")
+ if password and #password == 0 then
+ password = nil
+ end
+ end
+ -- NOTE: these go into client 'opts' table, not in 'conn'
+ opts.username = opts.username or username
+ assert(opts.username == nil or type(opts.username) == "string", "expected username to be a string")
+ opts.password = opts.password or password
+ assert(opts.password == nil or type(opts.password) == "string", "expected password to be a string")
+ assert(not conn.password or conn.username, "password is not accepted in absence of username")
+ -- print("username: ", username)
+ -- print("password: ", password)
+
+
+ local secure = opts.secure
if secure then
conn.secure = true
- if type(secure) == "table" then
- conn.secure_params = secure
- else
- conn.secure_params = {
- mode = "client",
- protocol = "tlsv1_2",
- verify = "none",
- options = "all",
- }
- end
- conn.ssl_module = args.ssl_module or "ssl"
+ conn.secure_params = secure ~= true and secure or nil
+ conn.ssl_module = opts.ssl_module or "ssl"
+ assert(conn.ssl_module == nil or type(conn.ssl_module) == "string", "expected ssl_module to be a string")
+ else
+ -- sanity
+ conn.secure = false
+ conn.secure_params = nil
+ conn.ssl_module = nil
end
end
-- Send given packet to opened network connection
-function client_mt:_send_packet(packet)
+function Client:_send_packet(packet)
local conn = self.connection
if not conn then
return false, "network connection is not opened"
@@ -1158,39 +1315,51 @@ function client_mt:_send_packet(packet)
return false, "sending empty packet"
end
-- and send binary packet to network connection
- local i, err = 1
- local send = self.args.connector.send
- while i < len do
- i, err = send(conn, data, i)
- if not i then
- return false, "connector.send failed: "..err
- end
+ local ok, err = conn:send(data)
+ if not ok then
+ return false, "connector.send failed: "..err
end
self.send_time = os_time()
return true
end
-- Receive one packet from established network connection
-function client_mt:_receive_packet()
+function Client:_receive_packet()
local conn = self.connection
if not conn then
return false, "network connection is not opened"
end
- -- parse packet
- local packet, err = self._parse_packet(conn.recv_func)
- if not packet then
+ -- read & parse packet
+ local packet, err = self._parse_packet(
+ function(size)
+ return conn:receive(size)
+ end
+ )
+ if packet then
+ -- succesful packet, clear handled data and return it
+ conn:buffer_clear()
+ return packet
+ end
+
+ -- check if we need more data, if not, clear the buffer because were done with
+ -- the data in that case
+ if err == conn.signal_idle then
+ -- we need more data, so do not clear buffer, just return the error
return false, err
end
- return packet
+
+ -- some other error, can't use buffered data, dispose of it
+ conn:buffer_clear()
+ return false, err
end
-- Represent MQTT client as string
-function client_mt:__tostring()
- return str_format("mqtt.client{id=%q}", tostring(self.args.id))
+function Client:__tostring()
+ return str_format("mqtt.client{id=%q}", tostring(self.opts.id))
end
-- Garbage collection handler
-function client_mt:__gc()
+function Client:__gc()
-- close network connection if it's available, without sending DISCONNECT packet
if self.connection then
self:close_connection("garbage")
@@ -1201,18 +1370,24 @@ end
-- @section exported
--- Create, initialize and return new MQTT client instance
--- @param ... see arguments of client_mt:__init(args)
--- @see client_mt:__init
--- @treturn client_mt MQTT client instance
-function client.create(...)
- local cl = setmetatable({}, client_mt)
- cl:__init(...)
+-- @name client.create
+-- @param ... see arguments of `Client:__init`
+-- @see Client:__init
+-- @treturn Client MQTT client instance
+function _M.create(opts)
+ local cl = setmetatable({}, Client)
+ cl:__init(opts)
return cl
end
-------
+if _G._TEST then
+ -- export functions for test purposes (different name!)
+ _M.__parse_connection_opts = Client._parse_connection_opts
+end
+
-- export module table
-return client
+return _M
-- vim: ts=4 sts=4 sw=4 noet ft=lua
diff --git a/controller-host/mqtt/connector/base/buffered_base.lua b/controller-host/mqtt/connector/base/buffered_base.lua
new file mode 100644
index 0000000..48ac0ce
--- /dev/null
+++ b/controller-host/mqtt/connector/base/buffered_base.lua
@@ -0,0 +1,89 @@
+-- base connector class for buffered reading.
+--
+-- Use this base class if the sockets do NOT yield.
+-- So LuaSocket for example, when using Copas or OpenResty
+-- use the non-buffered base class.
+--
+-- This base class derives from `non_buffered_base` it implements the
+-- `receive` and `buffer_clear` methods. But adds the `plain_receive` method
+-- that must be implemented.
+--
+-- NOTE: the `plain_receive` method is supposed to be non-blocking (see its
+-- description), but the `send` method has no such facilities, so is `blocking`
+-- in this class. Make sure to set the proper timeouts in either method before
+-- starting the send/receive. So for example for LuaSocket call `settimeout(0)`
+-- before receiving, and `settimeout(30)` before sending.
+--
+-- @class mqtt.connector.base.buffered_base
+
+
+local super = require "mqtt.connector.base.non_buffered_base"
+local buffered = setmetatable({}, super)
+buffered.__index = buffered
+buffered.super = super
+buffered.type = "buffered, blocking i/o"
+
+-- debug helper function
+-- function buffered:buffer_state(msg)
+-- print(string.format("buffer: size = %03d last-byte-done = %03d -- %s",
+-- #(self.buffer_string or ""), self.buffer_pointer or 0, msg))
+-- end
+
+-- bytes read were handled, clear those
+function buffered:buffer_clear()
+ -- self:buffer_state("before clearing buffer")
+ self.buffer_string = nil
+ self.buffer_pointer = nil
+end
+
+-- read bytes, first from buffer, remaining from function
+-- if function returns "idle" then reset read pointer
+function buffered:receive(size)
+ -- self:buffer_state("receive start "..size.." bytes")
+
+ local buf = self.buffer_string or ""
+ local idx = self.buffer_pointer or 0
+
+ while size > (#buf - idx) do
+ -- buffer is lacking bytes, read more...
+ local data, err = self:plain_receive(size - (#buf - idx))
+ if not data then
+ if err == self.signal_idle then
+ -- read timedout, retry entire packet later, reset buffer
+ self.buffer_pointer = 0
+ end
+ return data, err
+ end
+
+ -- append received data, and try again
+ buf = buf .. data
+ self.buffer_string = buf
+ -- self:buffer_state("receive added "..#data.." bytes")
+ end
+
+ self.buffer_pointer = idx + size
+ local data = buf:sub(idx + 1, idx + size)
+ -- print("data: ", require("mqtt.tools").hex(data))
+ -- self:buffer_state("receive done "..size.." bytes\n")
+ return data
+end
+
+--- Retrieves the requested number of bytes from the socket, in a non-blocking
+-- manner.
+-- The implementation MUST read with a timeout that immediately returns if there
+-- is no data to read. If there is no data, then it MUST return
+-- `nil, self.signal_idle` to indicate it no data was there and we need to retry later.
+--
+-- If there is partial data, it should return that data (less than the requested
+-- number of bytes), with no error/signal.
+--
+-- If the receive errors, because of a closed connection it should return
+-- `nil, self.signal_closed` to indicate this. Any other errors can be returned
+-- as a regular `nil, err`.
+-- @tparam size int number of bytes to receive.
+-- @return data, or `false, err`, where `err` can be a signal.
+function buffered:plain_receive(size) -- luacheck: ignore
+ error("method 'plain_receive' on buffered connector wasn't implemented")
+end
+
+return buffered
diff --git a/controller-host/mqtt/connector/base/luasec.lua b/controller-host/mqtt/connector/base/luasec.lua
new file mode 100644
index 0000000..660dfec
--- /dev/null
+++ b/controller-host/mqtt/connector/base/luasec.lua
@@ -0,0 +1,29 @@
+-- validates the LuaSec options, and applies defaults
+return function(conn)
+ if conn.secure then
+ local params = conn.secure_params
+ if not params then
+ -- set default LuaSec options
+ conn.secure_params = {
+ mode = "client",
+ protocol = "any",
+ verify = "none",
+ options = {"all", "no_sslv2", "no_sslv3", "no_tlsv1"},
+ }
+ return
+ end
+
+ local ok, ssl = pcall(require, conn.ssl_module)
+ assert(ok, "ssl_module '"..tostring(conn.ssl_module).."' not found, secure connections unavailable")
+
+ assert(type(params) == "table", "expecting .secure_params to be a table, got: "..type(params))
+
+ params.mode = params.mode or "client"
+ assert(params.mode == "client", "secure parameter 'mode' must be set to 'client' if given, got: "..tostring(params.mode))
+
+ local ctx, err = ssl.newcontext(params)
+ if not ctx then
+ error("Couldn't create secure context: "..tostring(err))
+ end
+ end
+end
diff --git a/controller-host/mqtt/connector/base/non_buffered_base.lua b/controller-host/mqtt/connector/base/non_buffered_base.lua
new file mode 100644
index 0000000..ded109e
--- /dev/null
+++ b/controller-host/mqtt/connector/base/non_buffered_base.lua
@@ -0,0 +1,67 @@
+-- base connector class for non-buffered reading.
+--
+-- Use this base class if the sockets DO yield.
+-- So Copas or OpenResty for example, when using LuaSocket
+-- use the buffered base class.
+--
+-- NOTE: when the send operation can also yield (as is the case with Copas and
+-- OpenResty) you should wrap the `send` handler in a lock to prevent a half-send
+-- message from being interleaved by another message send from another thread.
+--
+-- @class mqtt.connector.base.non_buffered_base
+
+
+local non_buffered = {
+ type = "non-buffered, yielding i/o",
+ timeout = 30 -- default timeout
+}
+non_buffered.__index = non_buffered
+
+-- we need to specify signals for these conditions such that the client
+-- doesn't have to rely on magic strings like "timeout", "wantread", etc.
+-- the connector is responsible for translating those connector specific
+-- messages to a generic signal
+non_buffered.signal_idle = {} -- read timeout occured, so we're idle need to come back later and try again
+non_buffered.signal_closed = {} -- remote closed the connection
+
+--- Validate connection options.
+function non_buffered:shutdown() -- luacheck: ignore
+ error("method 'shutdown' on connector wasn't implemented")
+end
+
+--- Clears consumed bytes.
+-- Called by the mqtt client when the consumed bytes from the buffer are handled
+-- and can be cleared from the buffer.
+-- A no-op for the non-buffered classes, since the sockets yield when incomplete.
+function non_buffered.buffer_clear()
+end
+
+--- Retrieves the requested number of bytes from the socket.
+-- If the receive errors, because of a closed connection it should return
+-- `nil, self.signal_closed` to indicate this. Any other errors can be returned
+-- as a regular `nil, err`.
+-- @tparam size int number of retrieve to return.
+-- @return data, or `false, err`, where `err` can be a signal.
+function non_buffered:receive(size) -- luacheck: ignore
+ error("method 'receive' on non-buffered connector wasn't implemented")
+end
+
+--- Open network connection to `self.host` and `self.port`.
+-- @return `true` on success, or `false, err` on failure
+function non_buffered:connect() -- luacheck: ignore
+ error("method 'connect' on connector wasn't implemented")
+end
+
+--- Shutdown the network connection.
+function non_buffered:shutdown() -- luacheck: ignore
+ error("method 'shutdown' on connector wasn't implemented")
+end
+
+--- Shutdown the network connection.
+-- @tparam data string data to send
+-- @return `true` on success, or `false, err` on failure
+function non_buffered:send(data) -- luacheck: ignore
+ error("method 'send' on connector wasn't implemented")
+end
+
+return non_buffered
diff --git a/controller-host/mqtt/connector/copas.lua b/controller-host/mqtt/connector/copas.lua
new file mode 100644
index 0000000..9926480
--- /dev/null
+++ b/controller-host/mqtt/connector/copas.lua
@@ -0,0 +1,121 @@
+--- Copas based connector.
+--
+-- Copas is an advanced coroutine scheduler in pure-Lua. It uses LuaSocket
+-- under the hood, but in a non-blocking way. It also uses LuaSec for TLS
+-- based connections (like the `mqtt.connector.luasocket` one). And hence uses
+-- the same defaults for the `secure` option when creating the `client`.
+--
+-- Caveats:
+--
+-- * the `client` option `ssl_module` is not supported by the Copas connector,
+-- It will always use the module named `ssl`.
+--
+-- * multiple threads can send simultaneously (sending is wrapped in a lock)
+--
+-- * since the client creates a long lived connection for reading, it returns
+-- upon receiving a packet, to call an event handler. The handler must return
+-- swiftly, since while the handler runs the socket will not be reading.
+-- Any task that might take longer than a few milliseconds should be off
+-- loaded to another thread (the Copas-loop will take care of this).
+--
+-- NOTE: you will need to install copas like this: `luarocks install copas`.
+-- @module mqtt.connector.copas
+
+local super = require "mqtt.connector.base.non_buffered_base"
+local connector = setmetatable({}, super)
+connector.__index = connector
+connector.super = super
+
+local socket = require("socket")
+local copas = require("copas")
+local new_lock = require("copas.lock").new
+local validate_luasec = require("mqtt.connector.base.luasec")
+
+
+-- validate connection options
+function connector:validate()
+ if self.secure then
+ assert(self.ssl_module == "ssl" or self.ssl_module == nil, "Copas connector only supports 'ssl' as 'ssl_module'")
+
+ validate_luasec(self)
+ end
+end
+
+-- Open network connection to .host and .port in conn table
+-- Store opened socket to conn table
+-- Returns true on success, or false and error text on failure
+function connector:connect()
+ self:validate()
+ local sock = copas.wrap(socket.tcp(), self.secure_params)
+ copas.setsocketname("mqtt@"..self.host..":"..self.port, sock)
+
+ sock:settimeouts(self.timeout, self.timeout, -1) -- no timout on reading
+
+ local ok, err = sock:connect(self.host, self.port)
+ if not ok then
+ return false, "copas.connect failed: "..err
+ end
+ self.sock = sock
+ self.send_lock = new_lock(30) -- 30 second timeout
+ return true
+end
+
+-- the packet was fully read, we can clear the bufer.
+function connector:buffer_clear()
+ -- since the packet is complete, we wait now indefinitely for the next one
+ self.sock:settimeouts(nil, nil, -1) -- no timeout on reading
+end
+
+-- Shutdown network connection
+function connector:shutdown()
+ self.sock:close()
+ self.send_lock:destroy()
+end
+
+-- Send data to network connection
+function connector:send(data)
+ -- cache locally in case lock/sock gets replaced while we were sending
+ local sock = self.sock
+ local lock = self.send_lock
+
+ local ok, err = lock:get()
+ if not ok then
+ return nil, "failed acquiring send_lock: "..tostring(err)
+ end
+
+ local i = 1
+ while i < #data do
+ i, err = sock:send(data, i)
+ if not i then
+ lock:release()
+ return false, err
+ end
+ end
+ lock:release()
+ return true
+end
+
+-- Receive given amount of data from network connection
+function connector:receive(size)
+ local sock = self.sock
+ local data, err = sock:receive(size)
+ if data then
+ -- bytes received, so change from idefinite timeout to regular until
+ -- packet is complete (see buffer_clear method)
+ self.sock:settimeouts(nil, nil, self.timeout)
+ return data
+ end
+
+ if err == "closed" then
+ return false, self.signal_closed
+ elseif err == "timout" then
+ return false, self.signal_idle
+ else
+ return false, err
+ end
+end
+
+-- export module table
+return connector
+
+-- vim: ts=4 sts=4 sw=4 noet ft=lua
diff --git a/controller-host/mqtt/connector/init.lua b/controller-host/mqtt/connector/init.lua
new file mode 100644
index 0000000..20db5fa
--- /dev/null
+++ b/controller-host/mqtt/connector/init.lua
@@ -0,0 +1,34 @@
+--- Auto detect the connector to use.
+-- The different environments require different socket implementations to work
+-- properly. The 'connectors' are an abstraction to facilitate that without
+-- having to modify the client itself.
+--
+-- This module is will auto-detect the environment and return the proper
+-- module from;
+--
+-- * `mqtt.connector.nginx` for using the non-blocking OpenResty co-socket apis
+--
+-- * `mqtt.connector.copas` for the non-blocking Copas wrapped sockets
+--
+-- * `mqtt.connector.luasocket` for LuaSocket based sockets (blocking)
+--
+-- Since the selection is based on a.o. packages loaded, make sure that in case
+-- of using the `copas` scheduler, you require it before the `mqtt` modules.
+--
+-- Since the `client` defaults to this module (`mqtt.connector`) there typically
+-- is no need to use this directly. When implementing your own connectors,
+-- the included connectors provide good examples of what to look out for.
+-- @module mqtt.connector
+
+local loops = setmetatable({
+ copas = "mqtt.connector.copas",
+ nginx = "mqtt.connector.nginx",
+ ioloop = "mqtt.connector.luasocket"
+}, {
+ __index = function()
+ error("failed to auto-detect connector to use, please set one explicitly", 2)
+ end
+})
+local loop = require("mqtt.loop.detect")()
+
+return require(loops[loop])
diff --git a/controller-host/mqtt/connector/luasocket.lua b/controller-host/mqtt/connector/luasocket.lua
new file mode 100644
index 0000000..b5bf004
--- /dev/null
+++ b/controller-host/mqtt/connector/luasocket.lua
@@ -0,0 +1,142 @@
+--- LuaSocket (and LuaSec) based connector.
+--
+-- This connector works with the blocking LuaSocket sockets. This connector uses
+-- `LuaSec` for TLS connections. This is the connector used for the included
+-- `mqtt.ioloop` scheduler.
+--
+-- When using TLS / MQTTS connections, the `secure` option passed to the `client`
+-- when creating it, can be the standard table of options as used by LuaSec
+-- for creating a context. When omitted the defaults will be;
+-- `{ mode="client", protocol="any", verify="none",
+-- options={ "all", "no_sslv2", "no_sslv3", "no_tlsv1" } }`
+--
+-- Caveats:
+--
+-- * since the client creates a long lived connection for reading, it returns
+-- upon receiving a packet, to call an event handler. The handler must return
+-- swiftly, since while the handler runs the socket will not be reading.
+-- Any task that might take longer than a few milliseconds should be off
+-- loaded to another task.
+--
+-- @module mqtt.connector.luasocket
+
+local super = require "mqtt.connector.base.buffered_base"
+local luasocket = setmetatable({}, super)
+luasocket.__index = luasocket
+luasocket.super = super
+
+local socket = require("socket")
+local validate_luasec = require("mqtt.connector.base.luasec")
+
+
+-- table with error messages that indicate a read timeout
+luasocket.timeout_errors = {
+ timeout = true, -- luasocket
+ wantread = true, -- luasec
+ wantwrite = true, -- luasec
+}
+
+-- validate connection options
+function luasocket:validate()
+ if self.secure then
+ validate_luasec(self)
+ end
+end
+
+-- Open network connection to .host and .port in conn table
+-- Store opened socket to conn table
+-- Returns true on success, or false and error text on failure
+function luasocket:connect()
+ self:validate()
+
+ local ssl
+ if self.secure then
+ ssl = require(self.ssl_module)
+ end
+
+ self:buffer_clear() -- sanity
+ local sock = socket.tcp()
+ sock:settimeout(self.timeout)
+
+ local ok, err = sock:connect(self.host, self.port)
+ if not ok then
+ return false, "socket.connect failed to connect to '"..tostring(self.host)..":"..tostring(self.port).."': "..err
+ end
+
+ if self.secure_params then
+ -- Wrap socket in TLS one
+ do
+ local wrapped
+ wrapped, err = ssl.wrap(sock, self.secure_params)
+ if not wrapped then
+ sock:close(self) -- close TCP level
+ return false, "ssl.wrap() failed: "..tostring(err)
+ end
+ -- replace sock with wrapped secure socket
+ sock = wrapped
+ end
+
+ -- do TLS/SSL initialization/handshake
+ sock:settimeout(self.timeout) -- sanity; again since its now a luasec socket
+ ok, err = sock:dohandshake()
+ if not ok then
+ sock:close()
+ return false, "ssl dohandshake failed: "..tostring(err)
+ end
+ end
+
+ self.sock = sock
+ return true
+end
+
+-- Shutdown network connection
+function luasocket:shutdown()
+ self.sock:close()
+end
+
+-- Send data to network connection
+function luasocket:send(data)
+ local sock = self.sock
+ local i = 0
+ local err
+
+ sock:settimeout(self.timeout)
+
+ while i < #data do
+ i, err = sock:send(data, i + 1)
+ if not i then
+ return false, err
+ end
+ end
+
+ return true
+end
+
+-- Receive given amount of data from network connection
+function luasocket:plain_receive(size)
+ local sock = self.sock
+
+ sock:settimeout(0)
+
+ local data, err, partial = sock:receive(size)
+
+ data = data or partial or ""
+ if #data > 0 then
+ return data
+ end
+
+ -- convert error to signal if required
+ if self.timeout_errors[err or -1] then
+ return false, self.signal_idle
+ elseif err == "closed" then
+ return false, self.signal_closed
+ else
+ return false, err
+ end
+end
+
+
+-- export module table
+return luasocket
+
+-- vim: ts=4 sts=4 sw=4 noet ft=lua
diff --git a/controller-host/mqtt/connector/nginx.lua b/controller-host/mqtt/connector/nginx.lua
new file mode 100644
index 0000000..97c30a9
--- /dev/null
+++ b/controller-host/mqtt/connector/nginx.lua
@@ -0,0 +1,102 @@
+--- Nginx OpenResty co-sockets based connector.
+--
+-- This connector works with the non-blocking openresty sockets. Note that the
+-- secure setting haven't been implemented yet. It will simply use defaults
+-- when doing a TLS handshake.
+--
+-- Caveats:
+--
+-- * sockets cannot cross phase/context boundaries. So all client interaction
+-- must be done from the timer context in which the client threads run.
+--
+-- * multiple threads cannot send simultaneously (simple scenarios will just
+-- work)
+--
+-- * since the client creates a long lived connection for reading, it returns
+-- upon receiving a packet, to call an event handler. The handler must return
+-- swiftly, since while the handler runs the socket will not be reading.
+-- Any task that might take longer than a few milliseconds should be off
+-- loaded to another thread.
+--
+-- * Nginx timers should be short lived because memory is only released after
+-- the context is destroyed. In this case we're using the fro prolonged periods
+-- of time, so be aware of this and implement client restarts if required.
+--
+-- thanks to @irimiab: https://github.com/xHasKx/luamqtt/issues/13
+-- @module mqtt.connector.nginx
+
+local super = require "mqtt.connector.base.non_buffered_base"
+local ngxsocket = setmetatable({}, super)
+ngxsocket.__index = ngxsocket
+ngxsocket.super = super
+
+-- load required stuff
+local ngx_socket_tcp = ngx.socket.tcp
+local long_timeout = 7*24*60*60*1000 -- one week
+
+-- validate connection options
+function ngxsocket:validate()
+ if self.secure then
+ assert(self.ssl_module == "ssl", "specifying custom ssl module when using Nginx connector is not supported")
+ assert(self.secure_params == nil or type(self.secure_params) == "table", "expecting .secure_params to be a table if given")
+ -- TODO: validate nginx stuff
+ end
+end
+
+-- Open network connection to .host and .port in conn table
+-- Store opened socket to conn table
+-- Returns true on success, or false and error text on failure
+function ngxsocket:connect()
+ -- TODO: add a lock for sending to prevent multiple threads from writing to
+ -- the same socket simultaneously (see the Copas connector)
+ local sock = ngx_socket_tcp()
+ -- set read-timeout to 'nil' to not timeout at all
+ sock:settimeouts(self.timeout * 1000, self.timeout * 1000, long_timeout) -- no timeout on reading
+ local ok, err = sock:connect(self.host, self.port)
+ if not ok then
+ return false, "socket:connect failed: "..err
+ end
+ if self.secure then
+ sock:sslhandshake()
+ end
+ self.sock = sock
+ return true
+end
+
+-- Shutdown network connection
+function ngxsocket:shutdown()
+ self.sock:close()
+end
+
+-- Send data to network connection
+function ngxsocket:send(data)
+ return self.sock:send(data)
+end
+
+function ngxsocket:buffer_clear()
+ -- since the packet is complete, we wait now indefinitely for the next one
+ self.sock:settimeouts(self.timeout * 1000, self.timeout * 1000, long_timeout) -- no timeout on reading
+end
+
+-- Receive given amount of data from network connection
+function ngxsocket:receive(size)
+ local sock = self.sock
+ local data, err = sock:receive(size)
+ if data then
+ -- bytes received, so change from idefinite timeout to regular until
+ -- packet is complete (see buffer_clear method)
+ self.sock:settimeouts(self.timeout * 1000, self.timeout * 1000, self.timeout * 1000)
+ return data
+ end
+
+ if err == "closed" then
+ return false, self.signal_closed
+ elseif err == "timout" then
+ return false, self.signal_idle
+ else
+ return false, err
+ end
+end
+
+-- export module table
+return ngxsocket
diff --git a/controller-host/mqtt/const.lua b/controller-host/mqtt/const.lua
new file mode 100644
index 0000000..c4e63bb
--- /dev/null
+++ b/controller-host/mqtt/const.lua
@@ -0,0 +1,19 @@
+--- MQTT const module
+
+--- Module table
+-- @tfield number v311 MQTT v3.1.1 protocol version constant
+-- @tfield number v50 MQTT v5.0 protocol version constant
+-- @tfield string _VERSION luamqtt library version string
+-- @table const
+local const = {
+ -- supported MQTT protocol versions
+ v311 = 4, -- supported protocol version, MQTT v3.1.1
+ v50 = 5, -- supported protocol version, MQTT v5.0
+
+ -- luamqtt library version string
+ _VERSION = "1.0.1",
+}
+
+return const
+
+-- vim: ts=4 sts=4 sw=4 noet ft=lua
diff --git a/controller-host/mqtt/init.lua b/controller-host/mqtt/init.lua
index e68bdd3..2e874f8 100644
--- a/controller-host/mqtt/init.lua
+++ b/controller-host/mqtt/init.lua
@@ -14,73 +14,253 @@ CONVENTIONS:
]]
--- Module table
--- @field v311 MQTT v3.1.1 protocol version constant
--- @field v50 MQTT v5.0 protocol version constant
--- @field _VERSION luamqtt version string
+-- @tfield number v311 MQTT v3.1.1 protocol version constant
+-- @tfield number v50 MQTT v5.0 protocol version constant
+-- @tfield string _VERSION luamqtt library version string
-- @table mqtt
-local mqtt = {
- -- supported MQTT protocol versions
- v311 = 4, -- supported protocol version, MQTT v3.1.1
- v50 = 5, -- supported protocol version, MQTT v5.0
+-- @see mqtt.const
+-- @usage
+-- local client = mqtt.client {
+-- uri = "mqtts://aladdin:soopersecret@mqttbroker.com",
+-- clean = true,
+-- version = mqtt.v50, -- specify constant for MQTT version
+-- }
+local mqtt = {}
- -- mqtt library version
- _VERSION = "3.4.3",
-}
+-- copy all values from const module
+local const = require("mqtt.const")
+for key, value in pairs(const) do
+ mqtt[key] = value
+end
-- load required stuff
-local type = type
+local log = require "mqtt.log"
+
local select = select
local require = require
local client = require("mqtt.client")
local client_create = client.create
-local ioloop_get = require("mqtt.ioloop").get
+local ioloop = require("mqtt.ioloop")
+local ioloop_get = ioloop.get
--- Create new MQTT client instance
--- @param ... Same as for mqtt.client.create(...)
--- @see mqtt.client.client_mt:__init
+-- @param ... Same as for `Client.create`(...)
+-- @see Client:__init
function mqtt.client(...)
return client_create(...)
end
---- Returns default ioloop instance
+--- Returns default `ioloop` instance. Shortcut to `Ioloop.get`.
-- @function mqtt.get_ioloop
+-- @see Ioloop.get
mqtt.get_ioloop = ioloop_get
---- Run default ioloop for given MQTT clients or functions
--- @param ... MQTT clients or lopp functions to add to ioloop
--- @see mqtt.ioloop.get
--- @see mqtt.ioloop.run_until_clients
+--- Run default `ioloop` for given MQTT clients or functions.
+-- Will not return until all clients/functions have exited.
+-- @param ... MQTT clients or loop functions to add to ioloop, see `Ioloop:add` for details on functions.
+-- @see Ioloop.get
+-- @see Ioloop.run_until_clients
+-- @usage
+-- mqtt.run_ioloop(client1, client2, func1)
function mqtt.run_ioloop(...)
+ log:info("starting default ioloop instance")
local loop = ioloop_get()
for i = 1, select("#", ...) do
local cl = select(i, ...)
loop:add(cl)
- if type(cl) ~= "function" then
- cl:start_connecting()
- end
end
return loop:run_until_clients()
end
---- Run synchronous input/output loop for only one given MQTT client.
--- Provided client's connection will be opened.
--- Client reconnect feature will not work, and keep_alive too.
--- @param cl MQTT client instance to run
-function mqtt.run_sync(cl)
- local ok, err = cl:start_connecting()
- if not ok then
- return false, err
+
+--- Validates a topic with wildcards.
+-- @param t (string) wildcard topic to validate
+-- @return topic, or false+error
+-- @usage local t = assert(mqtt.validate_subscribe_topic("base/+/thermostat/#"))
+function mqtt.validate_subscribe_topic(t)
+ if type(t) ~= "string" then
+ return false, "not a string"
end
- while cl.connection do
- ok, err = cl:_sync_iteration()
- if not ok then
- return false, err
+ if #t < 1 then
+ return false, "minimum topic length is 1"
+ end
+ do
+ local _, count = t:gsub("#", "")
+ if count > 1 then
+ return false, "wildcard '#' may only appear once"
+ end
+ if count == 1 then
+ if t ~= "#" and not t:find("/#$") then
+ return false, "wildcard '#' must be the last character, and be prefixed with '/' (unless the topic is '#')"
+ end
end
end
+ do
+ local t1 = "/"..t.."/"
+ local i = 1
+ while i do
+ i = t1:find("+", i)
+ if i then
+ if t1:sub(i-1, i+1) ~= "/+/" then
+ return false, "wildcard '+' must be enclosed between '/' (except at start/end)"
+ end
+ i = i + 1
+ end
+ end
+ end
+ return t
end
+--- Validates a topic without wildcards.
+-- @param t (string) topic to validate
+-- @return topic, or false+error
+-- @usage local t = assert(mqtt.validate_publish_topic("base/living/thermostat/setpoint"))
+function mqtt.validate_publish_topic(t)
+ if type(t) ~= "string" then
+ return false, "not a string"
+ end
+ if #t < 1 then
+ return false, "minimum topic length is 1"
+ end
+ if t:find("+", nil, true) or t:find("#", nil, true) then
+ return false, "wildcards '#', and '+' are not allowed when publishing"
+ end
+ return t
+end
+
+--- Returns a Lua pattern from topic.
+-- Takes a wildcarded-topic and returns a Lua pattern that can be used
+-- to validate if a received topic matches the wildcard-topic
+-- @param t (string) the wildcard topic
+-- @return Lua-pattern (string) or false+err
+-- @usage
+-- local patt = compile_topic_pattern("homes/+/+/#")
+--
+-- local topic = "homes/myhome/living/mainlights/brightness"
+-- local homeid, roomid, varargs = topic:match(patt)
+function mqtt.compile_topic_pattern(t)
+ local ok, err = mqtt.validate_subscribe_topic(t)
+ if not ok then
+ return ok, err
+ end
+ if t == "#" then
+ t = "(.+)" -- matches anything at least 1 character long
+ else
+ -- first replace valid mqtt '+' and '#' with placeholders
+ local hash = string.char(1)
+ t = t:gsub("/#$", "/" .. hash)
+ local plus = string.char(2)
+ t = t:gsub("^%+$", plus)
+ t = t:gsub("^%+/", plus .. "/")
+ local c = 1
+ while c ~= 0 do -- must loop, since adjacent patterns can overlap
+ t, c = t:gsub("/%+/", "/" .. plus .. "/")
+ end
+ t = t:gsub("/%+$", "/" .. plus)
+
+ -- now escape any special Lua pattern characters
+ t = t:gsub("[%\\%(%)%.%%%+%-%*%?%[%^%$]", function(cap) return "%"..cap end)
+
+ -- finally replace placeholders with captures
+ t = t:gsub(hash,"(.-)") -- match anything, can be empty
+ t = t:gsub(plus,"([^/]-)") -- match anything between '/', can be empty
+ end
+ return "^"..t.."$"
+end
+
+--- Parses wildcards in a topic into a table.
+-- @tparam topic string incoming topic string
+-- @tparam table opts parsing options table
+-- @tparam string opts.topic the wild-carded topic to match against (optional if `opts.pattern` is given)
+-- @tparam string opts.pattern the compiled pattern for the wild-carded topic (optional if `opts.topic`
+-- is given). If not given then topic will be compiled and the result will be
+-- stored in this field for future use (cache).
+-- @tparam array opts.keys array of field names. The order must be the same as the
+-- order of the wildcards in `topic`
+-- @treturn[1] table `fields`: the array part will have the values of the wildcards, in
+-- the order they appeared. The hash part, will have the field names provided
+-- in `opts.keys`, with the values of the corresponding wildcard. If a `#`
+-- wildcard was used, that one will be the last in the table.
+-- @treturn[1] `varargs`: The returned table is an array, with all segments that were
+-- matched by the `#` wildcard (empty if there was no `#` wildcard).
+-- @treturn[2] boolean `false` if there was no match
+-- @return[3] `false`+err on error, eg. pattern was invalid.
+-- @usage
+-- local opts = {
+-- topic = "homes/+/+/#",
+-- keys = { "homeid", "roomid", "varargs"},
+-- }
+-- local fields, varargst = topic_match("homes/myhome/living/mainlights/brightness", opts)
+--
+-- print(fields[1], fields.homeid) -- "myhome myhome"
+-- print(fields[2], fields.roomid) -- "living living"
+-- print(fields[3], fields.varargs) -- "mainlights/brightness mainlights/brightness"
+--
+-- print(varargst[1]) -- "mainlights"
+-- print(varargst[2]) -- "brightness"
+function mqtt.topic_match(topic, opts)
+ if type(topic) ~= "string" then
+ return false, "expected topic to be a string"
+ end
+ if type(opts) ~= "table" then
+ return false, "expected options to be a table"
+ end
+ local pattern = opts.pattern
+ if not pattern then
+ local ptopic = opts.topic
+ if not ptopic then
+ return false, "either 'opts.topic' or 'opts.pattern' must set"
+ end
+ local err
+ pattern, err = mqtt.compile_topic_pattern(ptopic)
+ if not pattern then
+ return false, "failed to compile 'opts.topic' into pattern: "..tostring(err)
+ end
+ -- store/cache compiled pattern for next time
+ opts.pattern = pattern
+ end
+ local values = { topic:match(pattern) }
+ if values[1] == nil then
+ return false
+ end
+ local keys = opts.keys
+ if keys ~= nil then
+ if type(keys) ~= "table" then
+ return false, "expected 'opts.keys' to be a table (array)"
+ end
+ -- we have a table with keys, copy values to fields
+ for i, value in ipairs(values) do
+ local key = keys[i]
+ if key ~= nil then
+ values[key] = value
+ end
+ end
+ end
+ if not pattern:find("%(%.[%-%+]%)%$$") then -- pattern for "#" as last char
+ -- we're done
+ return values, {}
+ end
+ -- we have a '#' wildcard
+ local vararg = values[#values]
+ local varargs = {}
+ local i = 0
+ local ni = 0
+ while ni do
+ ni = vararg:find("/", i, true)
+ if ni then
+ varargs[#varargs + 1] = vararg:sub(i, ni-1)
+ i = ni + 1
+ else
+ varargs[#varargs + 1] = vararg:sub(i, -1)
+ end
+ end
+
+ return values, varargs
+end
+
+
-- export module table
return mqtt
diff --git a/controller-host/mqtt/ioloop.lua b/controller-host/mqtt/ioloop.lua
index b903c9a..94b895f 100644
--- a/controller-host/mqtt/ioloop.lua
+++ b/controller-host/mqtt/ioloop.lua
@@ -1,35 +1,28 @@
---- ioloop module
--- @module mqtt.ioloop
--- @alias ioloop
+--- This class contains the ioloop implementation.
+--
+-- In short: allowing you to work with several MQTT clients in one script, and allowing them to maintain
+-- a long-term connection to broker, using PINGs. This is the bundled alternative to Copas and Nginx.
+--
+-- NOTE: this module will work only with MQTT clients using the `connector.luasocket` connector.
+--
+-- Providing an IO loop instance dealing with efficient (as much as possible in limited lua IO) network communication
+-- for several MQTT clients in the same OS thread.
+-- The main idea is that you are creating an ioloop instance, then adding MQTT clients to it.
+-- Then ioloop is starting an endless loop trying to receive/send data for all added MQTT clients.
+-- You may add more or remove some MQTT clients to/from the ioloop after it has been created and started.
+--
+-- Using an ioloop is allowing you to run a MQTT client for long time, through sending PINGREQ packets to broker
+-- in keepAlive interval to maintain long-term connection.
+--
+-- Also, any function can be added to the ioloop instance, and it will be called in the same endless loop over and over
+-- alongside with added MQTT clients to provide you a piece of processor time to run your own logic (like running your own
+-- network communications or any other thing good working in an io-loop)
+-- @classmod Ioloop
---[[
- ioloop module
-
- In short: allowing you to work with several MQTT clients in one script, and allowing them to maintain
- a long-term connection to broker, using PINGs.
-
- NOTE: this module will work only with MQTT clients using standard luasocket/luasocket_ssl connectors.
-
- In long:
- Providing an IO loop instance dealing with efficient (as much as possible in limited lua IO) network communication
- for several MQTT clients in the same OS thread.
- The main idea is that you are creating an ioloop instance, then adding created and connected MQTT clients to it.
- The ioloop instance is setting a non-blocking mode for sockets in MQTT clients and setting a small timeout
- for their receive/send operations. Then ioloop is starting an endless loop trying to receive/send data for all added MQTT clients.
- You may add more or remove some MQTT clients from the ioloop after it's created and started.
-
- Using that ioloop is allowing you to run a MQTT client for long time, through sending PINGREQ packets to broker
- in keepAlive interval to maintain long-term connection.
-
- Also, any function can be added to the ioloop instance, and it will be called in the same endless loop over and over
- alongside with added MQTT clients to provide you a piece of processor time to run your own logic (like running your own
- network communications or any other thing good working in an io-loop)
-]]
-
--- module table
-local ioloop = {}
+local _M = {}
-- load required stuff
+local log = require "mqtt.log"
local next = next
local type = type
local ipairs = ipairs
@@ -39,135 +32,214 @@ local setmetatable = setmetatable
local table = require("table")
local tbl_remove = table.remove
---- ioloop instances metatable
--- @type ioloop_mt
-local ioloop_mt = {}
-ioloop_mt.__index = ioloop_mt
+local math = require("math")
+local math_min = math.min
---- Initialize ioloop instance
--- @tparam table args ioloop creation arguments table
--- @tparam[opt=0.005] number args.timeout network operations timeout in seconds
--- @tparam[opt=0] number args.sleep sleep interval after each iteration
--- @tparam[opt] function args.sleep_function custom sleep function to call after each iteration
--- @treturn ioloop_mt ioloop instance
-function ioloop_mt:__init(args)
- args = args or {}
- args.timeout = args.timeout or 0.005
- args.sleep = args.sleep or 0
- args.sleep_function = args.sleep_function or require("socket").sleep
- self.args = args
+--- ioloop instances metatable
+local Ioloop = {}
+Ioloop.__index = Ioloop
+
+--- Initialize ioloop instance.
+-- @tparam table opts ioloop creation options table
+-- @tparam[opt=0] number opts.sleep_min min sleep interval after each iteration
+-- @tparam[opt=0.002] number opts.sleep_step increase in sleep after every idle iteration
+-- @tparam[opt=0.030] number opts.sleep_max max sleep interval after each iteration
+-- @tparam[opt=luasocket.sleep] function opts.sleep_function custom sleep function to call after each iteration
+-- @treturn Ioloop ioloop instance
+function Ioloop:__init(opts)
+ log:debug("initializing ioloop instance '%s'", tostring(self))
+ opts = opts or {}
+ opts.sleep_min = opts.sleep_min or 0
+ opts.sleep_step = opts.sleep_step or 0.002
+ opts.sleep_max = opts.sleep_max or 0.030
+ opts.sleep_function = opts.sleep_function or require("socket").sleep
+ self.opts = opts
self.clients = {}
+ self.timeouts = setmetatable({}, { __mode = "v" })
self.running = false --ioloop running flag, used by MQTT clients which are adding after this ioloop started to run
end
---- Add MQTT client or a loop function to the ioloop instance
--- @tparam client_mt|function client MQTT client or a loop function to add to ioloop
+--- Add MQTT client or a loop function to the ioloop instance.
+-- When adding a function, the function should on each call return the time (in seconds) it wishes to sleep. The ioloop
+-- will sleep after each iteration based on what clients/functions returned. So the function may be called sooner than
+-- the requested time, but will not be called later.
+-- @tparam client_mt|function client MQTT client or a loop function to add to ioloop
-- @return true on success or false and error message on failure
-function ioloop_mt:add(client)
+-- @usage
+-- -- create a timer on a 1 second interval
+-- local timer do
+-- local interval = 1
+-- local next_call = socket.gettime() + interval
+-- timer = function()
+-- if next_call >= socket.gettime() then
+--
+-- -- do stuff here
+--
+-- next_call = socket.gettime() + interval
+-- return interval
+-- else
+-- return next_call - socket.gettime()
+-- end
+-- end
+-- end
+--
+-- loop:add(timer)
+function Ioloop:add(client)
local clients = self.clients
if clients[client] then
- return false, "such MQTT client or loop function is already added to this ioloop"
+ if type(client) == "table" then
+ log:warn("MQTT client '%s' was already added to ioloop '%s'", client.opts.id, tostring(self))
+ return false, "MQTT client was already added to this ioloop"
+ else
+ log:warn("MQTT loop function '%s' was already added to this ioloop '%s'", tostring(client), tostring(self))
+ return false, "MQTT loop function was already added to this ioloop"
+ end
end
clients[#clients + 1] = client
clients[client] = true
+ self.timeouts[client] = self.opts.sleep_min
- -- associate ioloop with adding MQTT client
- if type(client) ~= "function" then
- client:set_ioloop(self)
+ if type(client) == "table" then
+ log:info("adding client '%s' to ioloop '%s'", client.opts.id, tostring(self))
+ -- create and add function for PINGREQ
+ local function f()
+ if not clients[client] then
+ -- the client were supposed to do keepalive for is gone, remove ourselves
+ self:remove(f)
+ end
+ return client:check_keep_alive()
+ end
+ -- add it to start doing keepalive checks
+ self:add(f)
+ else
+ log:info("adding function '%s' to ioloop '%s'", tostring(client), tostring(self))
end
return true
end
--- Remove MQTT client or a loop function from the ioloop instance
--- @tparam client_mt|function client MQTT client or a loop function to remove from ioloop
+-- @tparam client_mt|function client MQTT client or a loop function to remove from ioloop
-- @return true on success or false and error message on failure
-function ioloop_mt:remove(client)
+function Ioloop:remove(client)
local clients = self.clients
if not clients[client] then
- return false, "no such MQTT client or loop function was added to ioloop"
+ if type(client) == "table" then
+ log:warn("MQTT client not found '%s' in ioloop '%s'", client.opts.id, tostring(self))
+ return false, "MQTT client not found"
+ else
+ log:warn("MQTT loop function not found '%s' in ioloop '%s'", tostring(client), tostring(self))
+ return false, "MQTT loop function not found"
+ end
end
- clients[client] = nil
-- search an index of client to remove
for i, item in ipairs(clients) do
if item == client then
+ -- found it, remove
tbl_remove(clients, i)
+ clients[client] = nil
break
end
end
- -- unlink ioloop from MQTT client
- if type(client) ~= "function" then
- client:set_ioloop(nil)
+ if type(client) == "table" then
+ log:info("removed client '%s' from ioloop '%s'", client.opts.id, tostring(self))
+ else
+ log:info("removed loop function '%s' from ioloop '%s'", tostring(client), tostring(self))
end
return true
end
---- Perform one ioloop iteration
-function ioloop_mt:iteration()
- self.timeouted = false
+--- Perform one ioloop iteration.
+-- TODO: make this smarter do not wake-up functions or clients returning a longer
+-- sleep delay. Currently they will be tried earlier if another returns a smaller delay.
+function Ioloop:iteration()
+ local opts = self.opts
+ local sleep = opts.sleep_max
+
for _, client in ipairs(self.clients) do
+ local t, err
+ -- read data and handle events
if type(client) ~= "function" then
- client:_ioloop_iteration()
+ t, err = client:step()
else
- client()
+ t = client() or opts.sleep_max
end
+ if t == -1 then
+ -- no data read, client is idle, step up timeout
+ t = math_min(self.timeouts[client] + opts.sleep_step, opts.sleep_max)
+ self.timeouts[client] = t
+ elseif not t then
+ -- an error from a client was returned
+ if not client.opts.reconnect then
+ -- error and not reconnecting, remove the client
+ log:info("client '%s' returned '%s', no re-connect set, removing client", client.opts.id, err)
+ self:remove(client)
+ t = opts.sleep_max
+ else
+ -- error, but will reconnect
+ log:error("client '%s' failed with '%s', will try re-connecting", client.opts.id, err)
+ t = opts.sleep_min -- try asap
+ end
+ else
+ -- a number of seconds was returned
+ t = math_min(t, opts.sleep_max)
+ self.timeouts[client] = opts.sleep_min
+ end
+ sleep = math_min(sleep, t)
end
-- sleep a bit
- local args = self.args
- local sleep = args.sleep
if sleep > 0 then
- args.sleep_function(sleep)
+ opts.sleep_function(sleep)
end
end
---- Perform sleep if no one of the network operation in current iteration was not timeouted
-function ioloop_mt:can_sleep()
- if not self.timeouted then
- local args = self.args
- args.sleep_function(args.timeout)
- self.timeouted = true
- end
-end
+--- Run the ioloop.
+-- While there is at least one client/function in the ioloop it will continue
+-- iterating. After all clients/functions are gone, it will return.
+function Ioloop:run_until_clients()
+ log:info("ioloop started with %d clients/functions", #self.clients)
---- Run ioloop until at least one client are in ioloop
-function ioloop_mt:run_until_clients()
self.running = true
while next(self.clients) do
self:iteration()
end
self.running = false
+
+ log:info("ioloop finished with %d clients/functions", #self.clients)
end
--------
+--- Exported functions
+-- @section exported
+
--- Create IO loop instance with given options
--- @see ioloop_mt:__init
--- @treturn ioloop_mt ioloop instance
-local function ioloop_create(args)
- local inst = setmetatable({}, ioloop_mt)
- inst:__init(args)
+-- @name ioloop.create
+-- @see Ioloop:__init
+-- @treturn Ioloop ioloop instance
+function _M.create(opts)
+ local inst = setmetatable({}, Ioloop)
+ inst:__init(opts)
return inst
end
-ioloop.create = ioloop_create
-- Default ioloop instance
local ioloop_instance
--- Returns default ioloop instance
+-- @name ioloop.get
-- @tparam[opt=true] boolean autocreate Automatically create ioloop instance
--- @tparam[opt] table args Arguments for creating ioloop instance
--- @treturn ioloop_mt ioloop instance
-function ioloop.get(autocreate, args)
+-- @tparam[opt] table opts Arguments for creating ioloop instance
+-- @treturn Ioloop ioloop instance
+function _M.get(autocreate, opts)
if autocreate == nil then
autocreate = true
end
- if autocreate then
- if not ioloop_instance then
- ioloop_instance = ioloop_create(args)
- end
+ if autocreate and not ioloop_instance then
+ log:info("auto-creating default ioloop instance")
+ ioloop_instance = _M.create(opts)
end
return ioloop_instance
end
@@ -175,6 +247,6 @@ end
-------
-- export module table
-return ioloop
+return _M
-- vim: ts=4 sts=4 sw=4 noet ft=lua
diff --git a/controller-host/mqtt/log.lua b/controller-host/mqtt/log.lua
new file mode 100644
index 0000000..42a6964
--- /dev/null
+++ b/controller-host/mqtt/log.lua
@@ -0,0 +1,17 @@
+-- logging
+
+-- returns a LuaLogging compatible logger object if LuaLogging was already loaded
+-- otherwise returns a stub
+
+local ll = package.loaded.logging
+if ll and type(ll) == "table" and ll.defaultLogger and
+ tostring(ll._VERSION):find("LuaLogging") then
+ -- default LuaLogging logger is available
+ return ll.defaultLogger()
+else
+ -- just use a stub logger with only no-op functions
+ local nop = function() end
+ return setmetatable({}, {
+ __index = function(self, key) self[key] = nop return nop end
+ })
+end
diff --git a/controller-host/mqtt/loop/copas.lua b/controller-host/mqtt/loop/copas.lua
new file mode 100644
index 0000000..026ccd9
--- /dev/null
+++ b/controller-host/mqtt/loop/copas.lua
@@ -0,0 +1,72 @@
+--- Copas specific client handling module.
+-- Typically this module is not used directly, but through `mqtt.loop` when
+-- auto-detecting the environment.
+-- @module mqtt.loop.copas
+
+local copas = require "copas"
+local log = require "mqtt.log"
+
+local client_registry = {}
+
+local _M = {}
+
+
+--- Add MQTT client to the Copas scheduler.
+-- Each received packet will be handled by a new thread, such that the thread
+-- listening on the socket can return immediately.
+-- The client will automatically be removed after it exits. It will set up a
+-- thread to call `Client:check_keep_alive`.
+-- @param cl mqtt-client to add to the Copas scheduler
+-- @return `true` on success or `false` and error message on failure
+function _M.add(cl)
+ if client_registry[cl] then
+ log:warn("MQTT client '%s' was already added to Copas", cl.opts.id)
+ return false, "MQTT client was already added to Copas"
+ end
+ client_registry[cl] = true
+
+ do -- make mqtt device async for incoming packets
+ local handle_received_packet = cl.handle_received_packet
+ local count = 0
+ -- replace packet handler; create a new thread for each packet received
+ cl.handle_received_packet = function(mqttdevice, packet)
+ count = count + 1
+ copas.addnamedthread(handle_received_packet, cl.opts.id..":receive_"..count, mqttdevice, packet)
+ return true
+ end
+ end
+
+ -- add keep-alive timer
+ local timer = copas.addnamedthread(function()
+ while client_registry[cl] do
+ local next_check = cl:check_keep_alive()
+ if next_check > 0 then
+ copas.pause(next_check)
+ end
+ end
+ end, cl.opts.id .. ":keep_alive")
+
+ -- add client to connect and listen
+ copas.addnamedthread(function()
+ while client_registry[cl] do
+ local timeout = cl:step()
+ if not timeout then
+ client_registry[cl] = nil -- exiting
+ log:debug("MQTT client '%s' exited, removed from Copas", cl.opts.id)
+ copas.wakeup(timer)
+ else
+ if timeout > 0 then
+ copas.pause(timeout)
+ end
+ end
+ end
+ end, cl.opts.id .. ":listener")
+
+ return true
+end
+
+return setmetatable(_M, {
+ __call = function(self, ...)
+ return self.add(...)
+ end,
+})
diff --git a/controller-host/mqtt/loop/detect.lua b/controller-host/mqtt/loop/detect.lua
new file mode 100644
index 0000000..4fc15b6
--- /dev/null
+++ b/controller-host/mqtt/loop/detect.lua
@@ -0,0 +1,30 @@
+--- Module returns a single function to detect the io-loop in use.
+-- Either 'copas', 'nginx', or 'ioloop', or nil+error
+local log = require "mqtt.log"
+
+local loop
+return function()
+ if loop then return loop end
+ if type(ngx) == "table" then
+ -- there is a global 'ngx' table, so we're running OpenResty
+ log:info("LuaMQTT auto-detected Nginx as the runtime environment")
+ loop = "nginx"
+ return loop
+
+ elseif package.loaded.copas then
+ -- 'copas' was already loaded
+ log:info("LuaMQTT auto-detected Copas as the io-loop in use")
+ loop = "copas"
+ return loop
+
+ elseif pcall(require, "socket") and tostring(require("socket")._VERSION):find("LuaSocket") then
+ -- LuaSocket is available
+ log:info("LuaMQTT auto-detected LuaSocket as the socket library to use with mqtt-ioloop")
+ loop = "ioloop"
+ return loop
+
+ else
+ -- unknown
+ return nil, "LuaMQTT io-loop/connector auto-detection failed, please specify one explicitly"
+ end
+end
diff --git a/controller-host/mqtt/loop/init.lua b/controller-host/mqtt/loop/init.lua
new file mode 100644
index 0000000..d499c2e
--- /dev/null
+++ b/controller-host/mqtt/loop/init.lua
@@ -0,0 +1,37 @@
+--- Auto detect the IO loop to use.
+-- Interacting with the supported IO loops (ioloop, copas, and nginx) requires
+-- specific implementations to get it right.
+-- This module will auto-detect the environment and return the proper
+-- module from;
+--
+-- * `mqtt.loop.ioloop`
+--
+-- * `mqtt.loop.copas`
+--
+-- * `mqtt.loop.nginx`
+--
+-- Since the selection is based on a.o. packages loaded, make sure that in case
+-- of using the `copas` scheduler, you require it before the `mqtt` modules.
+--
+-- @usage
+-- --local copas = require "copas" -- only if you use Copas
+-- local mqtt = require "mqtt"
+-- local add_client = require("mqtt.loop").add -- returns a loop-specific function
+--
+-- local client = mqtt.create { ... options ... }
+-- add_client(client) -- works for ioloop, copas, and nginx
+--
+-- @module mqtt.loop
+
+local loops = setmetatable({
+ copas = "mqtt.loop.copas",
+ nginx = "mqtt.loop.nginx",
+ ioloop = "mqtt.loop.ioloop"
+}, {
+ __index = function()
+ error("failed to auto-detect connector to use, please set one explicitly", 2)
+ end
+})
+local loop = require("mqtt.loop.detect")()
+
+return require(loops[loop])
diff --git a/controller-host/mqtt/loop/ioloop.lua b/controller-host/mqtt/loop/ioloop.lua
new file mode 100644
index 0000000..d585651
--- /dev/null
+++ b/controller-host/mqtt/loop/ioloop.lua
@@ -0,0 +1,24 @@
+--- IOloop specific client handling module.
+-- Typically this module is not used directly, but through `mqtt.loop` when
+-- auto-detecting the environment.
+-- @module mqtt.loop.ioloop
+
+local _M = {}
+
+local mqtt = require "mqtt"
+
+--- Add MQTT client to the integrated ioloop.
+-- The client will automatically be removed after it exits. It will set up a
+-- function to call `Client:check_keep_alive` in the ioloop.
+-- @param client mqtt-client to add to the ioloop
+-- @return `true` on success or `false` and error message on failure
+function _M.add(client)
+ local default_loop = mqtt.get_ioloop()
+ return default_loop:add(client)
+end
+
+return setmetatable(_M, {
+ __call = function(self, ...)
+ return self.add(...)
+ end,
+})
diff --git a/controller-host/mqtt/loop/nginx.lua b/controller-host/mqtt/loop/nginx.lua
new file mode 100644
index 0000000..d3d944d
--- /dev/null
+++ b/controller-host/mqtt/loop/nginx.lua
@@ -0,0 +1,76 @@
+--- Nginx specific client handling module.
+-- Typically this module is not used directly, but through `mqtt.loop` when
+-- auto-detecting the environment.
+-- @module mqtt.loop.nginx
+
+local client_registry = {}
+
+local _M = {}
+
+
+--- Add MQTT client to the Nginx environment.
+-- The client will automatically be removed after it exits. It will set up a
+-- thread to call `Client:check_keep_alive`.
+-- @param client mqtt-client to add to the Nginx environment
+-- @return `true` on success or `false` and error message on failure
+function _M.add(client)
+ if client_registry[client] then
+ ngx.log(ngx.WARN, "MQTT client '%s' was already added to Nginx", client.opts.id)
+ return false, "MQTT client was already added to Nginx"
+ end
+
+ do -- make mqtt device async for incoming packets
+ local handle_received_packet = client.handle_received_packet
+
+ -- replace packet handler; create a new thread for each packet received
+ client.handle_received_packet = function(mqttdevice, packet)
+ ngx.thread.spawn(handle_received_packet, mqttdevice, packet)
+ return true
+ end
+ end
+
+
+ local ok, err = ngx.timer.at(0, function()
+ -- spawn a thread to listen on the socket
+ local coro = ngx.thread.spawn(function()
+ while true do
+ local sleeptime = client:step()
+ if not sleeptime then
+ ngx.log(ngx.INFO, "MQTT client '", client.opts.id, "' exited, stopping client-thread")
+ client_registry[client] = nil
+ return
+ else
+ if sleeptime > 0 then
+ ngx.sleep(sleeptime * 1000)
+ end
+ end
+ end
+ end)
+
+ -- endless keep-alive loop
+ while not ngx.worker.exiting() do
+ ngx.sleep((client:check_keep_alive())) -- double (()) to trim to 1 argument
+ end
+
+ -- exiting
+ client_registry[client] = nil
+ ngx.log(ngx.DEBUG, "MQTT client '", client.opts.id, "' keep-alive loop exited")
+ client:disconnect()
+ ngx.thread.wait(coro)
+ ngx.log(ngx.DEBUG, "MQTT client '", client.opts.id, "' exit complete")
+ end)
+
+ if not ok then
+ ngx.log(ngx.CRIT, "Failed to start timer-context for device '", client.id,"': ", err)
+ return false, "timer failed: " .. err
+ end
+
+ return true
+end
+
+
+return setmetatable(_M, {
+ __call = function(self, ...)
+ return self.add(...)
+ end,
+})
diff --git a/controller-host/mqtt/luasocket-copas.lua b/controller-host/mqtt/luasocket-copas.lua
deleted file mode 100644
index 069229e..0000000
--- a/controller-host/mqtt/luasocket-copas.lua
+++ /dev/null
@@ -1,48 +0,0 @@
--- DOC: https://keplerproject.github.io/copas/
--- NOTE: you will need to install copas like this: luarocks install copas
-
--- module table
-local connector = {}
-
-local socket = require("socket")
-local copas = require("copas")
-
--- Open network connection to .host and .port in conn table
--- Store opened socket to conn table
--- Returns true on success, or false and error text on failure
-function connector.connect(conn)
- local sock, err = socket.connect(conn.host, conn.port)
- if not sock then
- return false, "socket.connect failed: "..err
- end
- conn.sock = sock
- return true
-end
-
--- Shutdown network connection
-function connector.shutdown(conn)
- conn.sock:shutdown()
-end
-
--- Send data to network connection
-function connector.send(conn, data, i, j)
- local ok, err = copas.send(conn.sock, data, i, j)
- return ok, err
-end
-
--- Receive given amount of data from network connection
-function connector.receive(conn, size)
- local ok, err = copas.receive(conn.sock, size)
- return ok, err
-end
-
--- Set connection's socket to non-blocking mode and set a timeout for it
-function connector.settimeout(conn, timeout)
- conn.timeout = timeout
- conn.sock:settimeout(0)
-end
-
--- export module table
-return connector
-
--- vim: ts=4 sts=4 sw=4 noet ft=lua
diff --git a/controller-host/mqtt/luasocket.lua b/controller-host/mqtt/luasocket.lua
deleted file mode 100644
index b189c2d..0000000
--- a/controller-host/mqtt/luasocket.lua
+++ /dev/null
@@ -1,54 +0,0 @@
--- DOC: http://w3.impa.br/~diego/software/luasocket/tcp.html
-
--- module table
-local luasocket = {}
-
-local socket = require("socket")
-
--- Open network connection to .host and .port in conn table
--- Store opened socket to conn table
--- Returns true on success, or false and error text on failure
-function luasocket.connect(conn)
- local sock, err = socket.connect(conn.host, conn.port)
- if not sock then
- return false, "socket.connect failed: "..err
- end
- conn.sock = sock
- return true
-end
-
--- Shutdown network connection
-function luasocket.shutdown(conn)
- conn.sock:shutdown()
-end
-
--- Send data to network connection
-function luasocket.send(conn, data, i, j)
- conn.sock:settimeout(nil, "t")
- local ok, err = conn.sock:send(data, i, j)
- conn.sock:settimeout(conn.timeout, "t")
- -- print(" luasocket.send:", ok, err, require("mqtt.tools").hex(data))
- return ok, err
-end
-
--- Receive given amount of data from network connection
-function luasocket.receive(conn, size)
- local ok, err = conn.sock:receive(size)
- --if ok then
- -- print(" luasocket.receive good:", size, #ok, require("mqtt.tools").hex(ok))
- --elseif err ~= "timeout" then
- -- print(" luasocket.receive fail:", ok, err)
- --end
- return ok, err
-end
-
--- Set connection's socket to non-blocking mode and set a timeout for it
-function luasocket.settimeout(conn, timeout)
- conn.timeout = timeout
- conn.sock:settimeout(timeout, "b")
-end
-
--- export module table
-return luasocket
-
--- vim: ts=4 sts=4 sw=4 noet ft=lua
diff --git a/controller-host/mqtt/luasocket_ssl.lua b/controller-host/mqtt/luasocket_ssl.lua
deleted file mode 100644
index 15b31cb..0000000
--- a/controller-host/mqtt/luasocket_ssl.lua
+++ /dev/null
@@ -1,56 +0,0 @@
--- DOC: http://w3.impa.br/~diego/software/luasocket/tcp.html
-
--- module table
-local luasocket_ssl = {}
-
-local type = type
-local assert = assert
-local luasocket = require("mqtt.luasocket")
-
--- Open network connection to .host and .port in conn table
--- Store opened socket to conn table
--- Returns true on success, or false and error text on failure
-function luasocket_ssl.connect(conn)
- assert(type(conn.secure_params) == "table", "expecting .secure_params to be a table")
-
- -- open usual TCP connection
- local ok, err = luasocket.connect(conn)
- if not ok then
- return false, "luasocket connect failed: "..err
- end
- local wrapped
-
- -- load right ssl module
- local ssl = require(conn.ssl_module or "ssl")
-
- -- TLS/SSL initialization
- wrapped, err = ssl.wrap(conn.sock, conn.secure_params)
- if not wrapped then
- conn.sock:shutdown()
- return false, "ssl.wrap() failed: "..err
- end
- ok = wrapped:dohandshake()
- if not ok then
- conn.sock:shutdown()
- return false, "ssl dohandshake failed"
- end
-
- -- replace sock in connection table with wrapped secure socket
- conn.sock = wrapped
- return true
-end
-
--- Shutdown network connection
-function luasocket_ssl.shutdown(conn)
- conn.sock:close()
-end
-
--- Copy original methods from mqtt.luasocket module
-luasocket_ssl.send = luasocket.send
-luasocket_ssl.receive = luasocket.receive
-luasocket_ssl.settimeout = luasocket.settimeout
-
--- export module table
-return luasocket_ssl
-
--- vim: ts=4 sts=4 sw=4 noet ft=lua
diff --git a/controller-host/mqtt/ngxsocket.lua b/controller-host/mqtt/ngxsocket.lua
deleted file mode 100644
index 349780c..0000000
--- a/controller-host/mqtt/ngxsocket.lua
+++ /dev/null
@@ -1,55 +0,0 @@
--- module table
--- thanks to @irimiab: https://github.com/xHasKx/luamqtt/issues/13
-local ngxsocket = {}
-
--- load required stuff
-local string_sub = string.sub
-local ngx_socket_tcp = ngx.socket.tcp -- luacheck: ignore
-
--- Open network connection to .host and .port in conn table
--- Store opened socket to conn table
--- Returns true on success, or false and error text on failure
-function ngxsocket.connect(conn)
- local socket = ngx_socket_tcp()
- socket:settimeout(0x7FFFFFFF)
- local sock, err = socket:connect(conn.host, conn.port)
- if not sock then
- return false, "socket:connect failed: "..err
- end
- if conn.secure then
- socket:sslhandshake()
- end
- conn.sock = socket
- return true
-end
-
--- Shutdown network connection
-function ngxsocket.shutdown(conn)
- conn.sock:close()
-end
-
--- Send data to network connection
-function ngxsocket.send(conn, data, i, j)
- if i then
- return conn.sock:send(string_sub(data, i, j))
- else
- return conn.sock:send(data)
- end
-end
-
--- Receive given amount of data from network connection
-function ngxsocket.receive(conn, size)
- return conn.sock:receive(size)
-end
-
--- Set connection's socket to non-blocking mode and set a timeout for it
-function ngxsocket.settimeout(conn, timeout)
- if not timeout then
- conn.sock:settimeout(0x7FFFFFFF)
- else
- conn.sock:settimeout(timeout * 1000)
- end
-end
-
--- export module table
-return ngxsocket
diff --git a/controller-host/mqtt/protocol.lua b/controller-host/mqtt/protocol.lua
index b7e467f..ae34c43 100644
--- a/controller-host/mqtt/protocol.lua
+++ b/controller-host/mqtt/protocol.lua
@@ -6,10 +6,10 @@
Here is a generic implementation of MQTT protocols of all supported versions.
MQTT v3.1.1 documentation (DOCv3.1.1):
- http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/errata01/os/mqtt-v3.1.1-errata01-os-complete.html
+ DOC[1]: http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/errata01/os/mqtt-v3.1.1-errata01-os-complete.html
MQTT v5.0 documentation (DOCv5.0):
- http://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html
+ DOC[2]: http://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html
CONVENTIONS:
@@ -44,6 +44,10 @@ local str_char = string.char
local str_byte = string.byte
local str_format = string.format
+local const = require("mqtt.const")
+local const_v311 = const.v311
+local const_v50 = const.v50
+
local bit = require("mqtt.bitwrap")
local bor = bit.bor
local band = bit.band
@@ -52,26 +56,33 @@ local rshift = bit.rshift
local tools = require("mqtt.tools")
local div = tools.div
+local sortedpairs = tools.sortedpairs
--- Create uint8 value data
-local function make_uint8(val)
+--- Create bytes of the uint8 value
+-- @tparam number val - integer value to convert to bytes
+-- @treturn string bytes of the value
+function protocol.make_uint8(val)
if val < 0 or val > 0xFF then
error("value is out of range to encode as uint8: "..tostring(val))
end
return str_char(val)
end
-protocol.make_uint8 = make_uint8
+local make_uint8 = protocol.make_uint8
--- Create uint16 value data
-local function make_uint16(val)
+--- Create bytes of the uint16 value
+-- @tparam number val - integer value to convert to bytes
+-- @treturn string bytes of the value
+function protocol.make_uint16(val)
if val < 0 or val > 0xFFFF then
error("value is out of range to encode as uint16: "..tostring(val))
end
return str_char(rshift(val, 8), band(val, 0xFF))
end
-protocol.make_uint16 = make_uint16
+local make_uint16 = protocol.make_uint16
--- Create uint32 value data
+--- Create bytes of the uint32 value
+-- @tparam number val - integer value to convert to bytes
+-- @treturn string bytes of the value
function protocol.make_uint32(val)
if val < 0 or val > 0xFFFFFFFF then
error("value is out of range to encode as uint32: "..tostring(val))
@@ -79,18 +90,27 @@ function protocol.make_uint32(val)
return str_char(rshift(val, 24), band(rshift(val, 16), 0xFF), band(rshift(val, 8), 0xFF), band(val, 0xFF))
end
--- Create UTF-8 string data
--- DOCv3.1.1: 1.5.3 UTF-8 encoded strings
--- DOCv5.0: 1.5.4 UTF-8 Encoded String
+--- Create bytes of the UTF-8 string value according to the MQTT spec.
+-- Basically it's the same string with its length prefixed as uint16 value.
+-- For MQTT v3.1.1: 1.5.3 UTF-8 encoded strings,
+-- For MQTT v5.0: 1.5.4 UTF-8 Encoded String.
+-- @tparam string str - string value to convert to bytes
+-- @treturn string bytes of the value
function protocol.make_string(str)
return make_uint16(str:len())..str
end
--- Returns bytes of given integer value encoded as variable length field
--- DOCv3.1.1: 2.2.3 Remaining Length
--- DOCv5.0: 2.1.4 Remaining Length
-local function make_var_length(len)
- if len < 0 or len > 268435455 then
+--- Maximum integer value (268435455) that can be encoded using variable-length encoding
+protocol.max_variable_length = 268435455
+local max_variable_length = protocol.max_variable_length
+
+--- Create bytes of the integer value encoded as variable length field
+-- For MQTT v3.1.1: 2.2.3 Remaining Length,
+-- For MQTT v5.0: 2.1.4 Remaining Length.
+-- @tparam number len - integer value to be encoded
+-- @treturn string bytes of the value
+function protocol.make_var_length(len)
+ if len < 0 or len > max_variable_length then
error("value is invalid for encoding as variable length field: "..tostring(len))
end
local bytes = {}
@@ -106,9 +126,11 @@ local function make_var_length(len)
until len <= 0
return unpack(bytes)
end
-protocol.make_var_length = make_var_length
+local make_var_length = protocol.make_var_length
--- Make data for 1-byte property with only 0 or 1 value
+--- Make bytes for 1-byte value with only 0 or 1 value allowed
+-- @tparam number value - integer value to convert to bytes
+-- @treturn string bytes of the value
function protocol.make_uint8_0_or_1(value)
if value ~= 0 and value ~= 1 then
error("expecting 0 or 1 as value")
@@ -116,7 +138,9 @@ function protocol.make_uint8_0_or_1(value)
return make_uint8(value)
end
--- Make data for 2-byte property with nonzero value check
+--- Make bytes for 2-byte value with nonzero check
+-- @tparam number value - integer value to convert to bytes
+-- @treturn string bytes of the value
function protocol.make_uint16_nonzero(value)
if value == 0 then
error("expecting nonzero value")
@@ -124,7 +148,9 @@ function protocol.make_uint16_nonzero(value)
return make_uint16(value)
end
--- Make data for variable length property with nonzero value check
+--- Make bytes for variable length value with nonzero value check
+-- @tparam number value - integer value to convert to bytes
+-- @treturn string bytes of the value
function protocol.make_var_length_nonzero(value)
if value == 0 then
error("expecting nonzero value")
@@ -132,24 +158,29 @@ function protocol.make_var_length_nonzero(value)
return make_var_length(value)
end
--- Read string using given read_func function
--- Returns false plus error message on failure
--- Returns parsed string on success
+--- Read string (or bytes) using given read_func function
+-- @tparam function read_func - function to read some bytes from the network layer
+-- @treturn string parsed string (or bytes) on success
+-- @return OR false and error message on failure
function protocol.parse_string(read_func)
assert(type(read_func) == "function", "expecting read_func to be a function")
local len, err = read_func(2)
if not len then
return false, "failed to read string length: "..err
end
- -- convert len string from 2-byte integer
+ -- convert string length from 2 bytes
local byte1, byte2 = str_byte(len, 1, 2)
len = bor(lshift(byte1, 8), byte2)
- -- and return string if parsed length
+ -- and return string/bytes of the parsed length
return read_func(len)
end
+local parse_string = protocol.parse_string
--- Parse uint8 value using given read_func
-local function parse_uint8(read_func)
+--- Parse uint8 value using given read_func
+-- @tparam function read_func - function to read some bytes from the network layer
+-- @treturn number parser value
+-- @return OR false and error message on failure
+function protocol.parse_uint8(read_func)
assert(type(read_func) == "function", "expecting read_func to be a function")
local value, err = read_func(1)
if not value then
@@ -157,9 +188,12 @@ local function parse_uint8(read_func)
end
return str_byte(value, 1, 1)
end
-protocol.parse_uint8 = parse_uint8
+local parse_uint8 = protocol.parse_uint8
--- Parse uint8 value with only 0 or 1 value
+--- Parse uint8 value using given read_func with only 0 or 1 value allowed
+-- @tparam function read_func - function to read some bytes from the network layer
+-- @treturn number parser value
+-- @return OR false and error message on failure
function protocol.parse_uint8_0_or_1(read_func)
local value, err = parse_uint8(read_func)
if not value then
@@ -171,8 +205,11 @@ function protocol.parse_uint8_0_or_1(read_func)
return value
end
--- Parse uint16 value using given read_func
-local function parse_uint16(read_func)
+--- Parse uint16 value using given read_func
+-- @tparam function read_func - function to read some bytes from the network layer
+-- @treturn number parser value
+-- @return OR false and error message on failure
+function protocol.parse_uint16(read_func)
assert(type(read_func) == "function", "expecting read_func to be a function")
local value, err = read_func(2)
if not value then
@@ -181,9 +218,12 @@ local function parse_uint16(read_func)
local byte1, byte2 = str_byte(value, 1, 2)
return lshift(byte1, 8) + byte2
end
-protocol.parse_uint16 = parse_uint16
+local parse_uint16 = protocol.parse_uint16
--- Parse uint16 non-zero value using given read_func
+--- Parse uint16 non-zero value using given read_func
+-- @tparam function read_func - function to read some bytes from the network layer
+-- @treturn number parser value
+-- @return OR false and error message on failure
function protocol.parse_uint16_nonzero(read_func)
local value, err = parse_uint16(read_func)
if not value then
@@ -195,7 +235,10 @@ function protocol.parse_uint16_nonzero(read_func)
return value
end
--- Parse uint32 value using given read_func
+--- Parse uint32 value using given read_func
+-- @tparam function read_func - function to read some bytes from the network layer
+-- @treturn number parser value
+-- @return OR false and error message on failure
function protocol.parse_uint32(read_func)
assert(type(read_func) == "function", "expecting read_func to be a function")
local value, err = read_func(4)
@@ -210,11 +253,18 @@ function protocol.parse_uint32(read_func)
end
end
--- Max variable length integer value
+-- Max multiplier of the variable length integer value
local max_mult = 128 * 128 * 128
--- Returns variable length field value calling read_func function read data, DOC: 2.2.3 Remaining Length
-local function parse_var_length(read_func)
+--- Parse variable length field value using given read_func.
+-- For MQTT v3.1.1: 2.2.3 Remaining Length,
+-- For MQTT v5.0: 2.1.4 Remaining Length.
+-- @tparam function read_func - function to read some bytes from the network layer
+-- @treturn number parser value
+-- @return OR false and error message on failure
+function protocol.parse_var_length(read_func)
+ -- DOC[1]: 2.2.3 Remaining Length
+ -- DOC[2]: 1.5.5 Variable Byte Integer
assert(type(read_func) == "function", "expecting read_func to be a function")
local mult = 1
local val = 0
@@ -232,9 +282,14 @@ local function parse_var_length(read_func)
until band(byte, 128) == 0
return val
end
-protocol.parse_var_length = parse_var_length
+local parse_var_length = protocol.parse_var_length
--- Parse Variable Byte Integer with non-zero constraint
+--- Parse variable length field value using given read_func with non-zero constraint.
+-- For MQTT v3.1.1: 2.2.3 Remaining Length,
+-- For MQTT v5.0: 2.1.4 Remaining Length.
+-- @tparam function read_func - function to read some bytes from the network layer
+-- @treturn number parser value
+-- @return OR false and error message on failure
function protocol.parse_var_length_nonzero(read_func)
local value, err = parse_var_length(read_func)
if not value then
@@ -246,29 +301,40 @@ function protocol.parse_var_length_nonzero(read_func)
return value
end
--- Create fixed packet header data
--- DOCv3.1.1: 2.2 Fixed header
--- DOCv5.0: 2.1.1 Fixed Header
+--- Create bytes of the MQTT fixed packet header
+-- For MQTT v3.1.1: 2.2 Fixed header,
+-- For MQTT v5.0: 2.1.1 Fixed Header.
+-- @tparam number ptype - MQTT packet type
+-- @tparam number flags - MQTT packet flags
+-- @tparam number len - MQTT packet length
+-- @treturn string bytes of the fixed packet header
function protocol.make_header(ptype, flags, len)
local byte1 = bor(lshift(ptype, 4), band(flags, 0x0F))
return str_char(byte1, make_var_length(len))
end
--- Returns true if given value is a valid QoS
+--- Check if given value is a valid PUBLISH message QoS value
+-- @tparam number val - QoS value
+-- @treturn boolean true for valid QoS value, otherwise false
function protocol.check_qos(val)
return (val == 0) or (val == 1) or (val == 2)
end
--- Returns true if given value is a valid Packet Identifier
--- DOCv3.1.1: 2.3.1 Packet Identifier
--- DOCv5.0: 2.2.1 Packet Identifier
+--- Check if given value is a valid Packet Identifier
+-- For MQTT v3.1.1: 2.3.1 Packet Identifier,
+-- For MQTT v5.0: 2.2.1 Packet Identifier.
+-- @tparam number val - Packet ID value
+-- @treturn boolean true for valid Packet ID value, otherwise false
function protocol.check_packet_id(val)
return val >= 1 and val <= 0xFFFF
end
--- Returns the next Packet Identifier value relative to given current value
--- DOCv3.1.1: 2.3.1 Packet Identifier
--- DOCv5.0: 2.2.1 Packet Identifier
+--- Returns the next Packet Identifier value relative to given current value.
+-- If current is nil - returns 1 as the first possible Packet ID.
+-- For MQTT v3.1.1: 2.3.1 Packet Identifier,
+-- For MQTT v5.0: 2.2.1 Packet Identifier.
+-- @tparam[opt] number curr - current Packet ID value
+-- @treturn number next Packet ID value
function protocol.next_packet_id(curr)
if not curr then
return 1
@@ -282,42 +348,42 @@ function protocol.next_packet_id(curr)
return curr
end
--- MQTT protocol fixed header packet types
--- DOCv3.1.1: 2.2.1 MQTT Control Packet type
--- DOCv5.0: 2.1.2 MQTT Control Packet type
-local packet_type = {
- CONNECT = 1,
- CONNACK = 2,
- PUBLISH = 3,
- PUBACK = 4,
- PUBREC = 5,
- PUBREL = 6,
- PUBCOMP = 7,
- SUBSCRIBE = 8,
- SUBACK = 9,
- UNSUBSCRIBE = 10,
- UNSUBACK = 11,
- PINGREQ = 12,
- PINGRESP = 13,
- DISCONNECT = 14,
- AUTH = 15, -- NOTE: new in MQTTv5.0
- [1] = "CONNECT",
- [2] = "CONNACK",
- [3] = "PUBLISH",
- [4] = "PUBACK",
- [5] = "PUBREC",
- [6] = "PUBREL",
- [7] = "PUBCOMP",
- [8] = "SUBSCRIBE",
- [9] = "SUBACK",
- [10] = "UNSUBSCRIBE",
- [11] = "UNSUBACK",
- [12] = "PINGREQ",
- [13] = "PINGRESP",
- [14] = "DISCONNECT",
- [15] = "AUTH", -- NOTE: new in MQTTv5.0
+--- MQTT protocol fixed header packet types.
+-- For MQTT v3.1.1: 2.2.1 MQTT Control Packet type,
+-- For MQTT v5.0: 2.1.2 MQTT Control Packet type.
+protocol.packet_type = {
+ CONNECT = 1, -- 1
+ CONNACK = 2, -- 2
+ PUBLISH = 3, -- 3
+ PUBACK = 4, -- 4
+ PUBREC = 5, -- 5
+ PUBREL = 6, -- 6
+ PUBCOMP = 7, -- 7
+ SUBSCRIBE = 8, -- 8
+ SUBACK = 9, -- 9
+ UNSUBSCRIBE = 10, -- 10
+ UNSUBACK = 11, -- 11
+ PINGREQ = 12, -- 12
+ PINGRESP = 13, -- 13
+ DISCONNECT = 14, -- 14
+ AUTH = 15, -- 15
+ [1] = "CONNECT", -- "CONNECT"
+ [2] = "CONNACK", -- "CONNACK"
+ [3] = "PUBLISH", -- "PUBLISH"
+ [4] = "PUBACK", -- "PUBACK"
+ [5] = "PUBREC", -- "PUBREC"
+ [6] = "PUBREL", -- "PUBREL"
+ [7] = "PUBCOMP", -- "PUBCOMP"
+ [8] = "SUBSCRIBE", -- "SUBSCRIBE"
+ [9] = "SUBACK", -- "SUBACK"
+ [10] = "UNSUBSCRIBE", -- "UNSUBSCRIBE"
+ [11] = "UNSUBACK", -- "UNSUBACK"
+ [12] = "PINGREQ", -- "PINGREQ"
+ [13] = "PINGRESP", -- "PINGRESP"
+ [14] = "DISCONNECT", -- "DISCONNECT"
+ [15] = "AUTH", -- "AUTH"
}
-protocol.packet_type = packet_type
+local packet_type = protocol.packet_type
-- Packet types requiring packet identifier field
-- DOCv3.1.1: 2.3.1 Packet Identifier
@@ -334,7 +400,7 @@ local packets_requiring_packet_id = {
}
-- CONNACK return code/reason code strings
-local connack_rc = {
+protocol.connack_rc = {
-- MQTT v3.1.1 Connect return codes, DOCv3.1.1: 3.2.2.3 Connect Return code
[0] = "Connection Accepted",
[1] = "Connection Refused, unacceptable protocol version",
@@ -366,9 +432,11 @@ local connack_rc = {
[0x9D] = "Server moved",
[0x9F] = "Connection rate exceeded",
}
-protocol.connack_rc = connack_rc
+local connack_rc = protocol.connack_rc
--- Returns true if Packet Identifier field are required for given packet
+--- Check if Packet Identifier field are required for given packet
+-- @tparam table args - args for creating packet
+-- @treturn boolean true if Packet Identifier are required for the packet
function protocol.packet_id_required(args)
assert(type(args) == "table", "expecting args to be a table")
assert(type(args.type) == "number", "expecting .type to be a number")
@@ -410,7 +478,9 @@ combined_packet_mt.__index = function(_, key)
return combined_packet_mt[key]
end
--- Combine several data parts into one
+--- Combine several data parts into one
+-- @tparam combined_packet_mt/string ... any amount of strings of combined_packet_mt tables to combine into one packet
+-- @treturn combined_packet_mt table suitable to append packet parts or to stringify it into raw packet bytes
function protocol.combine(...)
return setmetatable({...}, combined_packet_mt)
end
@@ -422,7 +492,7 @@ local function value_tostring(value)
return str_format("%q", value)
elseif t == "table" then
local res = {}
- for k, v in pairs(value) do
+ for k, v in sortedpairs(value) do
if type(k) == "number" then
res[#res + 1] = value_tostring(v)
else
@@ -439,32 +509,36 @@ local function value_tostring(value)
end
end
--- Convert packet to string representation
-local function packet_tostring(packet)
+--- Render packet to string representation
+-- @tparam packet_mt packet table to convert to string
+-- @treturn string human-readable string representation of the packet
+function protocol.packet_tostring(packet)
local res = {}
- for k, v in pairs(packet) do
+ for k, v in sortedpairs(packet) do
res[#res + 1] = str_format("%s=%s", k, value_tostring(v))
end
return str_format("%s{%s}", tostring(packet_type[packet.type]), tbl_concat(res, ", "))
end
-protocol.packet_tostring = packet_tostring
+local packet_tostring = protocol.packet_tostring
--- Parsed packet metatable
+--- Parsed packet metatable
protocol.packet_mt = {
- __tostring = packet_tostring,
+ __tostring = packet_tostring, -- packet-to-human-readable-string conversion metamethod using protocol.packet_tostring()
}
--- Parsed CONNACK packet metatable
+--- Parsed CONNACK packet metatable
protocol.connack_packet_mt = {
- __tostring = packet_tostring,
+ __tostring = packet_tostring, -- packet-to-human-readable-string conversion metamethod using protocol.packet_tostring()
+ reason_string = function(self) -- Returns reason string for the CONNACK packet according to its rc field
+ local reason_string = connack_rc[self.rc]
+ if not reason_string then
+ reason_string = "Unknown: "..self.rc
+ end
+ return reason_string
+ end,
}
protocol.connack_packet_mt.__index = protocol.connack_packet_mt
---- Returns reason string for CONNACK packet
--- @treturn string Reason string according packet's rc field
-function protocol.connack_packet_mt:reason_string()
- return connack_rc[self.rc]
-end
--- Start parsing a new packet
-- @tparam function read_func - function to read data from the network connection
@@ -472,7 +546,7 @@ end
-- @treturn number flags
-- @treturn table input - a table with fields "read_func" and "available" representing a stream-like object
-- to read already received packet data in chunks
--- @return false and error_message on failure
+-- @return OR false and error_message on failure
function protocol.start_parse_packet(read_func)
assert(type(read_func) == "function", "expecting read_func to be a function")
local byte1, err, len, data
@@ -482,14 +556,14 @@ function protocol.start_parse_packet(read_func)
-- DOC[v5.0]: https://docs.oasis-open.org/mqtt/mqtt/v5.0/os/mqtt-v5.0-os.html#_Toc3901020
byte1, err = read_func(1)
if not byte1 then
- return false, "failed to read first byte: "..err
+ return false, err
end
byte1 = str_byte(byte1, 1, 1)
local ptype = rshift(byte1, 4)
local flags = band(byte1, 0xF)
len, err = parse_var_length(read_func)
if not len then
- return false, "failed to parse remaining length: "..err
+ return false, err
end
-- create packet parser instance (aka input)
@@ -500,14 +574,14 @@ function protocol.start_parse_packet(read_func)
data = ""
end
if not data then
- return false, "failed to read packet data: "..err
+ return false, err
end
input.available = data:len()
-- read data function for the input instance
input.read_func = function(size)
if size > input.available then
- return false, "not enough data to read size: "..size
+ return false, size
end
local off = input[1]
local res = str_sub(data, off, off + size - 1)
@@ -519,6 +593,101 @@ function protocol.start_parse_packet(read_func)
return ptype, flags, input
end
+--- Parse CONNECT packet with read_func
+-- @tparam function read_func - function to read data from the network connection
+-- @tparam[opt] number version - expected protocol version constant or nil to accept both versions
+-- @return packet on success or false and error message on failure
+function protocol.parse_packet_connect(read_func, version)
+ -- DOC[v3.1.1]: 3.1 CONNECT – Client requests a connection to a Server
+ -- DOC[v5.0]: 3.1 CONNECT – Connection Request
+ local ptype, flags, input = protocol.start_parse_packet(read_func)
+ if ptype ~= packet_type.CONNECT then
+ return false, "expecting CONNECT (1) packet type but got "..ptype
+ end
+ if flags ~= 0 then
+ return false, "expecting CONNECT flags to be 0 but got "..flags
+ end
+ return protocol.parse_packet_connect_input(input, version)
+end
+
+--- Parse CONNECT packet from already received stream-like packet input table
+-- @tparam table input - a table with fields "read_func" and "available" representing a stream-like object
+-- @tparam[opt] number version - expected protocol version constant or nil to accept both versions
+-- @return packet on success or false and error message on failure
+function protocol.parse_packet_connect_input(input, version)
+ -- DOC[v3.1.1]: 3.1 CONNECT – Client requests a connection to a Server
+ -- DOC[v5.0]: 3.1 CONNECT – Connection Request
+ local read_func = input.read_func
+ local err, protocol_name, protocol_ver, connect_flags, keep_alive
+
+ -- DOC: 3.1.2.1 Protocol Name
+ protocol_name, err = parse_string(read_func)
+ if not protocol_name then
+ return false, "failed to parse protocol name: "..err
+ end
+ if protocol_name ~= "MQTT" then
+ return false, "expecting 'MQTT' as protocol name but received '"..protocol_name.."'"
+ end
+
+ -- DOC[v3.1.1]: 3.1.2.2 Protocol Level
+ -- DOC[v5.0]: 3.1.2.2 Protocol Version
+ protocol_ver, err = parse_uint8(read_func)
+ if not protocol_ver then
+ return false, "failed to parse protocol level/version: "..err
+ end
+ if version ~= nil and version ~= protocol_ver then
+ return false, "expecting protocol version "..version.." but received "..protocol_ver
+ end
+
+ -- DOC: 3.1.2.3 Connect Flags
+ connect_flags, err = parse_uint8(read_func)
+ if not connect_flags then
+ return false, "failed to parse connect flags: "..err
+ end
+ if band(connect_flags, 0x1) ~= 0 then
+ return false, "reserved 1st bit in connect flags are set"
+ end
+ local clean = (band(connect_flags, 0x2) ~= 0)
+ local will = (band(connect_flags, 0x4) ~= 0)
+ local will_qos = band(rshift(connect_flags, 3), 0x3)
+ local will_retain = (band(connect_flags, 0x20) ~= 0)
+ local password_flag = (band(connect_flags, 0x40) ~= 0)
+ local username_flag = (band(connect_flags, 0x80) ~= 0)
+
+ -- DOC: 3.1.2.10 Keep Alive
+ keep_alive, err = parse_uint16(read_func)
+ if not keep_alive then
+ return false, "failed to parse keep alive field: "..err
+ end
+
+ -- continue parsing based on the protocol_ver
+
+ -- preparing common connect packet fields
+ local packet = {
+ type = packet_type.CONNECT,
+ version = protocol_ver,
+ clean = clean,
+ password = password_flag, -- NOTE: will be replaced
+ username = username_flag, -- NOTE: will be replaced
+ keep_alive = keep_alive,
+ }
+ if will then
+ packet.will = {
+ qos = will_qos,
+ retain = will_retain,
+ topic = "", -- NOTE: will be replaced
+ payload = "", -- NOTE: will be replaced
+ }
+ end
+ if protocol_ver == const_v311 then
+ return require("mqtt.protocol4")._parse_packet_connect_continue(input, packet)
+ elseif protocol_ver == const_v50 then
+ return require("mqtt.protocol5")._parse_packet_connect_continue(input, packet)
+ else
+ return false, "unexpected protocol version to continue parsing: "..protocol_ver
+ end
+end
+
-- export module table
return protocol
diff --git a/controller-host/mqtt/protocol4.lua b/controller-host/mqtt/protocol4.lua
index 6e8bb8b..e2d78e2 100644
--- a/controller-host/mqtt/protocol4.lua
+++ b/controller-host/mqtt/protocol4.lua
@@ -18,6 +18,9 @@ local require = require
local tostring = tostring
local setmetatable = setmetatable
+local const = require("mqtt.const")
+local const_v311 = const.v311
+
local bit = require("mqtt.bitwrap")
local bor = bit.bor
local band = bit.band
@@ -36,6 +39,8 @@ local packet_type = protocol.packet_type
local packet_mt = protocol.packet_mt
local connack_packet_mt = protocol.connack_packet_mt
local start_parse_packet = protocol.start_parse_packet
+local parse_packet_connect_input = protocol.parse_packet_connect_input
+local parse_string = protocol.parse_string
local parse_uint8 = protocol.parse_uint8
local parse_uint16 = protocol.parse_uint16
@@ -128,6 +133,31 @@ local function make_packet_connect(args)
return combine(header, variable_header, payload)
end
+-- Create CONNACK packet, DOC: 3.2 CONNACK – Acknowledge connection request
+local function make_packet_connack(args)
+ -- check args
+ assert(type(args.sp) == "boolean", "expecting .sp to be a boolean")
+ assert(type(args.rc) == "number", "expecting .rc to be a boolean")
+ -- DOC: 3.2.2.1 Connect Acknowledge Flags
+ -- DOC: 3.2.2.2 Session Present
+ local byte1
+ if args.sp then
+ byte1 = 1 -- bit 0 of the Connect Acknowledge Flags.
+ else
+ byte1 = 0
+ end
+ -- DOC: 3.2.2.3 Connect Return code
+ local byte2 = args.rc
+ -- DOC: 3.2.2 Variable header
+ local variable_header = combine(
+ make_uint8(byte1),
+ make_uint8(byte2)
+ )
+ -- DOC: 3.2.1 Fixed header
+ local header = make_header(packet_type.CONNACK, 0, variable_header:len()) -- NOTE: fixed flags value 0x0
+ return combine(header, variable_header)
+end
+
-- Create PUBLISH packet, DOC: 3.3 PUBLISH – Publish message
local function make_packet_publish(args)
-- check args
@@ -254,6 +284,29 @@ local function make_packet_subscribe(args)
return combine(header, variable_header, payload)
end
+-- Create SUBACK packet, DOC: 3.9 SUBACK – Subscribe acknowledgement
+local function make_packet_suback(args)
+ -- check args
+ assert(type(args.packet_id) == "number", "expecting .packet_id to be a number")
+ assert(check_packet_id(args.packet_id), "expecting .packet_id to be a valid Packet Identifier")
+ assert(type(args.rc) == "table", "expecting .rc to be a table")
+ assert(#args.rc > 0, "expecting .rc to be a non-empty array")
+ -- DOC: 3.9.2 Variable header
+ local variable_header = combine(
+ make_uint16(args.packet_id)
+ )
+ -- DOC: 3.9.3 Payload
+ local payload = combine()
+ for i, rc in ipairs(args.rc) do
+ assert(type(rc) == "number", "expecting .rc["..i.."] to be a number")
+ assert(rc >= 0 and rc <= 255, "expecting .rc["..i.."] to be in range [0, 255]")
+ payload:append(make_uint8(rc))
+ end
+ -- DOC: 3.9.1 Fixed header
+ local header = make_header(packet_type.SUBACK, 0, variable_header:len() + payload:len()) -- NOTE: fixed flags value 0x0
+ return combine(header, variable_header, payload)
+end
+
-- Create UNSUBSCRIBE packet, DOC: 3.10 UNSUBSCRIBE – Unsubscribe from topics
local function make_packet_unsubscribe(args)
-- check args
@@ -276,151 +329,427 @@ local function make_packet_unsubscribe(args)
return combine(header, variable_header, payload)
end
+-- Create UNSUBACK packet, DOC: 3.11 UNSUBACK – Unsubscribe acknowledgement
+local function make_packet_unsuback(args)
+ -- check args
+ assert(type(args.packet_id) == "number", "expecting .packet_id to be a number")
+ assert(check_packet_id(args.packet_id), "expecting .packet_id to be a valid Packet Identifier")
+ -- DOC: 3.11.2 Variable header
+ local variable_header = combine(
+ make_uint16(args.packet_id)
+ )
+ -- DOC: 3.11.3 Payload
+ -- The UNSUBACK Packet has no payload.
+ -- DOC: 3.11.1 Fixed header
+ local header = make_header(packet_type.UNSUBACK, 0, variable_header:len()) -- NOTE: fixed flags value 0x0
+ return combine(header, variable_header)
+end
+
-- Create packet of given {type: number} in args
function protocol4.make_packet(args)
assert(type(args) == "table", "expecting args to be a table")
assert(type(args.type) == "number", "expecting .type number in args")
local ptype = args.type
- if ptype == packet_type.CONNECT then
+ if ptype == packet_type.CONNECT then -- 1
return make_packet_connect(args)
- elseif ptype == packet_type.PUBLISH then
+ elseif ptype == packet_type.CONNACK then -- 2
+ return make_packet_connack(args)
+ elseif ptype == packet_type.PUBLISH then -- 3
return make_packet_publish(args)
- elseif ptype == packet_type.PUBACK then
+ elseif ptype == packet_type.PUBACK then -- 4
return make_packet_puback(args)
- elseif ptype == packet_type.PUBREC then
+ elseif ptype == packet_type.PUBREC then -- 5
return make_packet_pubrec(args)
- elseif ptype == packet_type.PUBREL then
+ elseif ptype == packet_type.PUBREL then -- 6
return make_packet_pubrel(args)
- elseif ptype == packet_type.PUBCOMP then
+ elseif ptype == packet_type.PUBCOMP then -- 7
return make_packet_pubcomp(args)
- elseif ptype == packet_type.SUBSCRIBE then
+ elseif ptype == packet_type.SUBSCRIBE then -- 8
return make_packet_subscribe(args)
- elseif ptype == packet_type.UNSUBSCRIBE then
+ elseif ptype == packet_type.SUBACK then -- 9
+ return make_packet_suback(args)
+ elseif ptype == packet_type.UNSUBSCRIBE then -- 10
return make_packet_unsubscribe(args)
- elseif ptype == packet_type.PINGREQ then
+ elseif ptype == packet_type.UNSUBACK then -- 11
+ return make_packet_unsuback(args)
+ elseif ptype == packet_type.PINGREQ then -- 12
-- DOC: 3.12 PINGREQ – PING request
return combine("\192\000") -- 192 == 0xC0, type == 12, flags == 0
- elseif ptype == packet_type.DISCONNECT then
+ elseif ptype == packet_type.PINGRESP then -- 13
+ -- DOC: 3.13 PINGRESP – PING response
+ return combine("\208\000") -- 208 == 0xD0, type == 13, flags == 0
+ elseif ptype == packet_type.DISCONNECT then -- 14
-- DOC: 3.14 DISCONNECT – Disconnect notification
return combine("\224\000") -- 224 == 0xD0, type == 14, flags == 0
else
- error("unexpected packet type to make: "..ptype)
+ error("unexpected protocol4 packet type to make: "..ptype)
end
end
+-- Parse CONNACK packet, DOC: 3.2 CONNACK – Acknowledge connection request
+local function parse_packet_connack(ptype, flags, input)
+ -- DOC: 3.2.1 Fixed header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available ~= 2 then
+ return false, packet_type[ptype]..": expecting data of length 2 bytes"
+ end
+ local byte1, byte2 = parse_uint8(input.read_func), parse_uint8(input.read_func)
+ local sp = (band(byte1, 0x1) ~= 0)
+ return setmetatable({type=ptype, sp=sp, rc=byte2}, connack_packet_mt)
+end
+
+-- Parse PUBLISH packet, DOC: 3.3 PUBLISH – Publish message
+local function parse_packet_publish(ptype, flags, input)
+ -- DOC: 3.3.1.1 DUP
+ local dup = (band(flags, 0x8) ~= 0)
+ -- DOC: 3.3.1.2 QoS
+ local qos = band(rshift(flags, 1), 0x3)
+ -- DOC: 3.3.1.3 RETAIN
+ local retain = (band(flags, 0x1) ~= 0)
+ -- DOC: 3.3.2.1 Topic Name
+ if input.available < 2 then
+ return false, packet_type[ptype]..": expecting data of length at least 2 bytes"
+ end
+ local topic_len = parse_uint16(input.read_func)
+ if input.available < topic_len then
+ return false, packet_type[ptype]..": malformed packet: not enough data to parse topic"
+ end
+ local topic = input.read_func(topic_len)
+ -- DOC: 3.3.2.2 Packet Identifier
+ local packet_id
+ if qos > 0 then
+ -- DOC: 3.3.2.2 Packet Identifier
+ if input.available < 2 then
+ return false, packet_type[ptype]..": malformed packet: not enough data to parse packet_id"
+ end
+ packet_id = parse_uint16(input.read_func)
+ end
+ -- DOC: 3.3.3 Payload
+ local payload
+ if input.available > 0 then
+ payload = input.read_func(input.available)
+ end
+ return setmetatable({type=ptype, dup=dup, qos=qos, retain=retain, packet_id=packet_id, topic=topic, payload=payload}, packet_mt)
+end
+
+-- Parse PUBACK packet, DOC: 3.4 PUBACK – Publish acknowledgement
+local function parse_packet_puback(ptype, flags, input)
+ -- DOC: 3.4.1 Fixed header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available ~= 2 then
+ return false, packet_type[ptype]..": expecting data of length 2 bytes"
+ end
+ -- DOC: 3.4.2 Variable header
+ local packet_id = parse_uint16(input.read_func)
+ return setmetatable({type=ptype, packet_id=packet_id}, packet_mt)
+end
+
+-- Parse PUBREC packet, DOC: 3.5 PUBREC – Publish received (QoS 2 publish received, part 1)
+local function parse_packet_pubrec(ptype, flags, input)
+ -- DOC: 3.4.1 Fixed header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available ~= 2 then
+ return false, packet_type[ptype]..": expecting data of length 2 bytes"
+ end
+ -- DOC: 3.5.2 Variable header
+ local packet_id = parse_uint16(input.read_func)
+ return setmetatable({type=ptype, packet_id=packet_id}, packet_mt)
+end
+
+-- Parse PUBREL packet, DOC: 3.6 PUBREL – Publish release (QoS 2 publish received, part 2)
+local function parse_packet_pubrel(ptype, flags, input)
+ if flags ~= 2 then
+ -- DOC: The Server MUST treat any other value as malformed and close the Network Connection [MQTT-3.6.1-1].
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available ~= 2 then
+ return false, packet_type[ptype]..": expecting data of length 2 bytes"
+ end
+ -- DOC: 3.6.2 Variable header
+ local packet_id = parse_uint16(input.read_func)
+ return setmetatable({type=ptype, packet_id=packet_id}, packet_mt)
+end
+
+-- Parse PUBCOMP packet, DOC: 3.7 PUBCOMP – Publish complete (QoS 2 publish received, part 3)
+local function parse_packet_pubcomp(ptype, flags, input)
+ -- DOC: 3.7.1 Fixed header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available ~= 2 then
+ return false, packet_type[ptype]..": expecting data of length 2 bytes"
+ end
+ -- DOC: 3.7.2 Variable header
+ local packet_id = parse_uint16(input.read_func)
+ return setmetatable({type=ptype, packet_id=packet_id}, packet_mt)
+end
+
+-- Parse SUBSCRIBE packet, DOC: 3.8 SUBSCRIBE - Subscribe to topics
+local function parse_packet_subscribe(ptype, flags, input)
+ if flags ~= 2 then
+ -- DOC: The Server MUST treat any other value as malformed and close the Network Connection [MQTT-3.8.1-1].
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available < 5 then -- variable header (2) + payload: topic length (2) + qos (1)
+ -- DOC: The payload of a SUBSCRIBE packet MUST contain at least one Topic Filter / QoS pair. A SUBSCRIBE packet with no payload is a protocol violation [MQTT-3.8.3-3]
+ return false, packet_type[ptype]..": expecting data of length 5 bytes at least"
+ end
+ -- DOC: 3.8.2 Variable header
+ local packet_id = parse_uint16(input.read_func)
+ -- DOC: 3.8.3 Payload
+ local subscriptions = {}
+ while input.available > 0 do
+ local topic_filter, qos, err
+ topic_filter, err = parse_string(input.read_func)
+ if not topic_filter then
+ return false, packet_type[ptype]..": failed to parse topic filter: "..err
+ end
+ qos, err = parse_uint8(input.read_func)
+ if not qos then
+ return false, packet_type[ptype]..": failed to parse qos: "..err
+ end
+ subscriptions[#subscriptions + 1] = {
+ topic = topic_filter,
+ qos = qos,
+ }
+ end
+ return setmetatable({type=ptype, packet_id=packet_id, subscriptions=subscriptions}, packet_mt)
+end
+
+-- SUBACK return codes
+-- DOC: 3.9.3 Payload
+local suback_rc = {
+ [0x00] = "Success - Maximum QoS 0",
+ [0x01] = "Success - Maximum QoS 1",
+ [0x02] = "Success - Maximum QoS 2",
+ [0x80] = "Failure",
+}
+protocol4.suback_rc = suback_rc
+
+--- Parsed SUBACK packet metatable
+local suback_packet_mt = {
+ __tostring = protocol.packet_tostring, -- packet-to-human-readable-string conversion metamethod using protocol.packet_tostring()
+ reason_strings = function(self) -- Returns return codes descriptions for the SUBACK packet according to its rc field
+ local human_readable = {}
+ for i, rc in ipairs(self.rc) do
+ local return_code = suback_rc[rc]
+ if return_code then
+ human_readable[i] = return_code
+ else
+ human_readable[i] = "Unknown: "..tostring(rc)
+ end
+ end
+ return human_readable
+ end,
+}
+suback_packet_mt.__index = suback_packet_mt
+protocol4.suback_packet_mt = suback_packet_mt
+
+-- Parse SUBACK packet, DOC: 3.9 SUBACK – Subscribe acknowledgement
+local function parse_packet_suback(ptype, flags, input)
+ -- DOC: 3.9.1 Fixed header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available < 3 then
+ return false, packet_type[ptype]..": expecting data of length at least 3 bytes"
+ end
+ -- DOC: 3.9.2 Variable header
+ -- DOC: 3.9.3 Payload
+ local packet_id = parse_uint16(input.read_func)
+ local rc = {} -- DOC: The payload contains a list of return codes.
+ while input.available > 0 do
+ rc[#rc + 1] = parse_uint8(input.read_func)
+ end
+ return setmetatable({type=ptype, packet_id=packet_id, rc=rc}, suback_packet_mt)
+end
+
+-- Parse UNSUBSCRIBE packet, DOC: 3.10 UNSUBSCRIBE – Unsubscribe from topics
+local function parse_packet_unsubscribe(ptype, flags, input)
+ -- DOC: 3.10.1 Fixed header
+ if flags ~= 2 then
+ -- DOC: The Server MUST treat any other value as malformed and close the Network Connection [MQTT-3.10.1-1].
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available < 4 then -- variable header (2) + payload: topic length (2)
+ -- DOC: The Payload of an UNSUBSCRIBE packet MUST contain at least one Topic Filter. An UNSUBSCRIBE packet with no payload is a protocol violation [MQTT-3.10.3-2].
+ return false, packet_type[ptype]..": expecting data of length at least 4 bytes"
+ end
+ -- DOC: 3.10.2 Variable header
+ local packet_id = parse_uint16(input.read_func)
+ -- DOC: 3.10.3 Payload
+ local subscriptions = {}
+ while input.available > 0 do
+ local topic_filter, err = parse_string(input.read_func)
+ if not topic_filter then
+ return false, packet_type[ptype]..": failed to parse topic filter: "..err
+ end
+ subscriptions[#subscriptions + 1] = topic_filter
+ end
+ return setmetatable({type=ptype, packet_id=packet_id, subscriptions=subscriptions}, packet_mt)
+end
+
+-- Parse UNSUBACK packet, DOC: 3.11 UNSUBACK – Unsubscribe acknowledgement
+local function parse_packet_unsuback(ptype, flags, input)
+ -- DOC: 3.11.1 Fixed header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available ~= 2 then
+ return false, packet_type[ptype]..": expecting data of length 2 bytes"
+ end
+ -- DOC: 3.11.2 Variable header
+ local packet_id = parse_uint16(input.read_func)
+ return setmetatable({type=ptype, packet_id=packet_id}, packet_mt)
+end
+
+-- Parse PINGREQ packet, DOC: 3.12 PINGREQ – PING request
+local function parse_packet_pingreq(ptype, flags, input)
+ -- DOC: 3.12.1 Fixed header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available ~= 0 then
+ return false, packet_type[ptype]..": expecting data of length 0 bytes"
+ end
+ return setmetatable({type=ptype}, packet_mt)
+end
+
+-- Parse PINGRESP packet, DOC: 3.13 PINGRESP – PING response
+local function parse_packet_pingresp(ptype, flags, input)
+ -- DOC: 3.13.1 Fixed header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available ~= 0 then
+ return false, packet_type[ptype]..": expecting data of length 0 bytes"
+ end
+ return setmetatable({type=ptype}, packet_mt)
+end
+
+-- Parse DISCONNECT packet, DOC: 3.14 DISCONNECT – Disconnect notification
+local function parse_packet_disconnect(ptype, flags, input)
+ -- DOC: 3.14.1 Fixed header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available ~= 0 then
+ return false, packet_type[ptype]..": expecting data of length 0 bytes"
+ end
+ return setmetatable({type=ptype}, packet_mt)
+end
+
-- Parse packet using given read_func
-- Returns packet on success or false and error message on failure
function protocol4.parse_packet(read_func)
local ptype, flags, input = start_parse_packet(read_func)
if not ptype then
- return false, flags
+ return false, flags -- flags is error message in this case
end
- -- parse readed data according type in fixed header
- if ptype == packet_type.CONNACK then
- -- DOC: 3.2 CONNACK – Acknowledge connection request
- if input.available ~= 2 then
- return false, "expecting data of length 2 bytes"
- end
- local byte1, byte2 = parse_uint8(input.read_func), parse_uint8(input.read_func)
- local sp = (band(byte1, 0x1) ~= 0)
- return setmetatable({type=ptype, sp=sp, rc=byte2}, connack_packet_mt)
- elseif ptype == packet_type.PUBLISH then
- -- DOC: 3.3 PUBLISH – Publish message
- -- DOC: 3.3.1.1 DUP
- local dup = (band(flags, 0x8) ~= 0)
- -- DOC: 3.3.1.2 QoS
- local qos = band(rshift(flags, 1), 0x3)
- -- DOC: 3.3.1.3 RETAIN
- local retain = (band(flags, 0x1) ~= 0)
- -- DOC: 3.3.2.1 Topic Name
- if input.available < 2 then
- return false, "expecting data of length at least 2 bytes"
- end
- local topic_len = parse_uint16(input.read_func)
- if input.available < topic_len then
- return false, "malformed PUBLISH packet: not enough data to parse topic"
- end
- local topic = input.read_func(topic_len)
- -- DOC: 3.3.2.2 Packet Identifier
- local packet_id
- if qos > 0 then
- -- DOC: 3.3.2.2 Packet Identifier
- if input.available < 2 then
- return false, "malformed PUBLISH packet: not enough data to parse packet_id"
- end
- packet_id = parse_uint16(input.read_func)
- end
- -- DOC: 3.3.3 Payload
- local payload
- if input.available > 0 then
- payload = input.read_func(input.available)
- end
- return setmetatable({type=ptype, dup=dup, qos=qos, retain=retain, packet_id=packet_id, topic=topic, payload=payload}, packet_mt)
- elseif ptype == packet_type.PUBACK then
- -- DOC: 3.4 PUBACK – Publish acknowledgement
- if input.available ~= 2 then
- return false, "expecting data of length 2 bytes"
- end
- -- DOC: 3.4.2 Variable header
- local packet_id = parse_uint16(input.read_func)
- return setmetatable({type=ptype, packet_id=packet_id}, packet_mt)
- elseif ptype == packet_type.PUBREC then
- -- DOC: 3.5 PUBREC – Publish received (QoS 2 publish received, part 1)
- if input.available ~= 2 then
- return false, "expecting data of length 2 bytes"
- end
- -- DOC: 3.5.2 Variable header
- local packet_id = parse_uint16(input.read_func)
- return setmetatable({type=ptype, packet_id=packet_id}, packet_mt)
- elseif ptype == packet_type.PUBREL then
- -- DOC: 3.6 PUBREL – Publish release (QoS 2 publish received, part 2)
- if input.available ~= 2 then
- return false, "expecting data of length 2 bytes"
- end
- -- also flags should be checked to equals 2 by the server
- -- DOC: 3.6.2 Variable header
- local packet_id = parse_uint16(input.read_func)
- return setmetatable({type=ptype, packet_id=packet_id}, packet_mt)
- elseif ptype == packet_type.PUBCOMP then
- -- 3.7 PUBCOMP – Publish complete (QoS 2 publish received, part 3)
- if input.available ~= 2 then
- return false, "expecting data of length 2 bytes"
- end
- -- DOC: 3.7.2 Variable header
- local packet_id = parse_uint16(input.read_func)
- return setmetatable({type=ptype, packet_id=packet_id}, packet_mt)
- elseif ptype == packet_type.SUBACK then
- -- DOC: 3.9 SUBACK – Subscribe acknowledgement
- if input.available < 3 then
- return false, "expecting data of length at least 3 bytes"
- end
- -- DOC: 3.9.2 Variable header
- -- DOC: 3.9.3 Payload
- local packet_id = parse_uint16(input.read_func)
- local rc = {} -- DOC: The payload contains a list of return codes.
- while input.available > 0 do
- rc[#rc + 1] = parse_uint8(input.read_func)
- end
- return setmetatable({type=ptype, packet_id=packet_id, rc=rc}, packet_mt)
- elseif ptype == packet_type.UNSUBACK then
- -- DOC: 3.11 UNSUBACK – Unsubscribe acknowledgement
- if input.available ~= 2 then
- return false, "expecting data of length 2 bytes"
- end
- -- DOC: 3.11.2 Variable header
- local packet_id = parse_uint16(input.read_func)
- return setmetatable({type=ptype, packet_id=packet_id}, packet_mt)
- elseif ptype == packet_type.PINGRESP then
- -- DOC: 3.13 PINGRESP – PING response
- if input.available ~= 0 then
- return false, "expecting data of length 0 bytes"
- end
- return setmetatable({type=ptype}, packet_mt)
+ -- parse read data according type in fixed header
+ if ptype == packet_type.CONNECT then -- 1
+ return parse_packet_connect_input(input, const_v311)
+ elseif ptype == packet_type.CONNACK then -- 2
+ return parse_packet_connack(ptype, flags, input)
+ elseif ptype == packet_type.PUBLISH then -- 3
+ return parse_packet_publish(ptype, flags, input)
+ elseif ptype == packet_type.PUBACK then -- 4
+ return parse_packet_puback(ptype, flags, input)
+ elseif ptype == packet_type.PUBREC then -- 5
+ return parse_packet_pubrec(ptype, flags, input)
+ elseif ptype == packet_type.PUBREL then -- 6
+ return parse_packet_pubrel(ptype, flags, input)
+ elseif ptype == packet_type.PUBCOMP then -- 7
+ return parse_packet_pubcomp(ptype, flags, input)
+ elseif ptype == packet_type.SUBSCRIBE then -- 8
+ return parse_packet_subscribe(ptype, flags, input)
+ elseif ptype == packet_type.SUBACK then -- 9
+ return parse_packet_suback(ptype, flags, input)
+ elseif ptype == packet_type.UNSUBSCRIBE then -- 10
+ return parse_packet_unsubscribe(ptype, flags, input)
+ elseif ptype == packet_type.UNSUBACK then -- 11
+ return parse_packet_unsuback(ptype, flags, input)
+ elseif ptype == packet_type.PINGREQ then -- 12
+ return parse_packet_pingreq(ptype, flags, input)
+ elseif ptype == packet_type.PINGRESP then -- 13
+ return parse_packet_pingresp(ptype, flags, input)
+ elseif ptype == packet_type.DISCONNECT then -- 14
+ return parse_packet_disconnect(ptype, flags, input)
else
return false, "unexpected packet type received: "..tostring(ptype)
end
end
+-- Continue parsing of the MQTT v3.1.1 CONNECT packet
+-- Internally called from the protocol.parse_packet_connect_input() function
+-- Returns packet on success or false and error message on failure
+function protocol4._parse_packet_connect_continue(input, packet)
+ -- DOC: 3.1.3 Payload
+ -- These fields, if present, MUST appear in the order Client Identifier, Will Topic, Will Message, User Name, Password
+ local read_func = input.read_func
+ local client_id, err
+
+ -- DOC: 3.1.3.1 Client Identifier
+ client_id, err = parse_string(read_func)
+ if not client_id then
+ return false, "CONNECT: failed to parse client_id: "..err
+ end
+ packet.id = client_id
+
+ local will = packet.will
+ if will then
+ -- 3.1.3.2 Will Topic
+ local will_topic, will_payload
+ will_topic, err = parse_string(read_func)
+ if not will_topic then
+ return false, "CONNECT: failed to parse will_topic: "..err
+ end
+ will.topic = will_topic
+
+ -- DOC: 3.1.3.3 Will Message
+ will_payload, err = parse_string(read_func)
+ if not will_payload then
+ return false, "CONNECT: failed to parse will_payload: "..err
+ end
+ will.payload = will_payload
+ end
+
+ if packet.username then
+ -- DOC: 3.1.3.4 User Name
+ local username
+ username, err = parse_string(read_func)
+ if not username then
+ return false, "CONNECT: failed to parse username: "..err
+ end
+ packet.username = username
+ else
+ packet.username = nil
+ end
+
+ if packet.password then
+ -- DOC: 3.1.3.5 Password
+ if not packet.username then
+ return false, "CONNECT: MQTT v3.1.1 does not allow providing password without username"
+ end
+ local password
+ password, err = parse_string(read_func)
+ if not password then
+ return false, "CONNECT: failed to parse password: "..err
+ end
+ packet.password = password
+ else
+ packet.password = nil
+ end
+
+ return setmetatable(packet, packet_mt)
+end
+
-- export module table
return protocol4
diff --git a/controller-host/mqtt/protocol5.lua b/controller-host/mqtt/protocol5.lua
index de9c696..9055d74 100644
--- a/controller-host/mqtt/protocol5.lua
+++ b/controller-host/mqtt/protocol5.lua
@@ -14,6 +14,7 @@ local protocol5 = {}
local type = type
local error = error
local assert = assert
+local ipairs = ipairs
local require = require
local tostring = tostring
local setmetatable = setmetatable
@@ -26,6 +27,12 @@ local string = require("string")
local str_char = string.char
local fmt = string.format
+local const = require("mqtt.const")
+local const_v50 = const.v50
+
+local tools = require("mqtt.tools")
+local sortedpairs = tools.sortedpairs
+
local bit = require("mqtt.bitwrap")
local bor = bit.bor
local band = bit.band
@@ -57,6 +64,7 @@ local packet_type = protocol.packet_type
local packet_mt = protocol.packet_mt
local connack_packet_mt = protocol.connack_packet_mt
local start_parse_packet = protocol.start_parse_packet
+local parse_packet_connect_input = protocol.parse_packet_connect_input
-- Returns true if given value is a valid Retain Handling option, DOC: 3.8.3.1 Subscription Options
local function check_retain_handling(val)
@@ -181,8 +189,8 @@ local property_pairs = {
make = make_uint8_0_or_1,
parse = parse_uint8_0_or_1, },
{ 0x26, "user_property", -- NOTE: not implemented intentionally
- make = function(value_) error("not implemented") end,
- parse = function(read_func_) error("not implemented") end, },
+ make = function(value_) error("not implemented") end, -- luacheck: ignore
+ parse = function(read_func_) error("not implemented") end, }, -- luacheck: ignore
{ 0x27, "maximum_packet_size",
make = make_uint32,
parse = parse_uint32, },
@@ -282,7 +290,7 @@ local allowed_properties = {
[0x26] = true, -- DOC: 3.7.2.2.3 User Property
},
[packet_type.SUBSCRIBE] = {
- [0x0B] = true, -- DOC: 3.8.2.1.2 Subscription Identifier
+ [0x0B] = { multiple=false }, -- DOC: 3.8.2.1.2 Subscription Identifier -- DOC: It is a Protocol Error to include the Subscription Identifier more than once.
[0x26] = true, -- DOC: 3.8.2.1.3 User Property
},
[packet_type.SUBACK] = {
@@ -321,12 +329,14 @@ local function make_properties(ptype, args)
assert(type(args.properties) == "table", "expecting .properties to be a table")
-- validate all properties and append them to order list
local order = {}
- for name, value in pairs(args.properties) do
+ for name, value in sortedpairs(args.properties) do
assert(type(name) == "string", "expecting property name to be a string: "..tostring(name))
-- detect property identifier and check it's allowed for that packet type
local prop_id = assert(properties[name], "unknown property: "..tostring(name))
assert(prop_id ~= uprop_id, "user properties should be passed in .user_properties table")
- assert(allowed[prop_id], "property "..name.." is not allowed for packet type "..ptype)
+ if not allowed[prop_id] then
+ error("property "..name.." is not allowed for packet type "..packet_type[ptype])
+ end
order[#order + 1] = { prop_id, name, value }
end
-- sort props in the identifier ascending order
@@ -360,23 +370,40 @@ local function make_properties(ptype, args)
assert(type(args.user_properties) == "table", "expecting .user_properties to be a table")
assert(allowed[uprop_id], "user_property is not allowed for packet type "..ptype)
local order = {}
- for name, val in pairs(args.user_properties) do
- local ntype = type(name)
- if ntype == "string" then
- if type(val) ~= "string" then
- error(fmt("user property '%s' value should be a string", name))
+ local dups = {}
+ if args.user_properties[1] then
+ -- at first use array items as they given as {name, value} pairs with stable order
+ for i, pair in ipairs(args.user_properties) do
+ -- validate types for name and value
+ if type(pair) ~= "table" then
+ error(fmt("user property at position %d should be {name, value} table", i))
end
- order[#order + 1] = {name, val, 0}
- elseif ntype == "number" then
- if type(val) ~= "table" or type(val[1]) ~= "string" or type(val[2]) ~= "string" then
- error(fmt("user property at index %d should be a table with two strings", name))
+ if type(pair[1]) ~= "string" then
+ error(fmt("user property name at position %d should be a string", i))
+ end
+ if type(pair[2]) ~= "string" then
+ error(fmt("user property '%s' value at position %d should be a string", pair[1], i))
+ end
+ order[i] = pair
+ dups[pair[1]] = pair[2]
+ end
+ end
+ -- now add the rest of user properties given as string table keys
+ for name, val in sortedpairs(args.user_properties) do
+ if type(name) ~= "number" then -- skipping number keys as they already added above
+ -- validate types for name and value
+ if type(name) ~= "string" then
+ error(fmt("user property name '%s' should be a string", name))
+ end
+ if type(val) ~= "string" then
+ error(fmt("user property '%s' value '%s' should be a string", name, val))
+ end
+ -- check that name+value key already added
+ if dups[name] ~= val then
+ order[#order + 1] = {name, val}
end
- order[#order + 1] = {val[1], val[2], name}
- else
- error(fmt("unknown user property name type passed: %s", ntype))
end
end
- tbl_sort(order, function(a, b) if a[1] == b[1] then return a[3] < b[3] else return a[1] < b[1] end end)
for _, pair in ipairs(order) do
local name = pair[1]
local value = pair[2]
@@ -449,6 +476,25 @@ local function make_packet_connect(args)
return combine(header, variable_header, payload)
end
+-- Create CONNACK packet, DOC: 3.2 CONNACK – Connect acknowledgement
+local function make_packet_connack(args)
+ -- check args
+ assert(type(args.sp) == "boolean", "expecting .sp to be a boolean with Session Present flag")
+ assert(type(args.rc) == "number", "expecting .rc to be a number with Connect Reason Code")
+ -- DOC: 3.2.2 CONNACK Variable Header
+ local props = make_properties(packet_type.CONNACK, args)
+ local variable_header = combine(
+ make_uint8(args.sp and 1 or 0), -- DOC: 3.2.2.1.1 Session Present
+ make_uint8(args.rc), -- DOC: 3.2.2.2 Connect Reason Code
+ props -- DOC: 3.2.2.3 CONNACK Properties
+ )
+ -- DOC: 3.2.3 CONNACK Payload
+ -- DOC: The CONNACK packet has no Payload.
+ -- DOC: 3.2.1 CONNACK Fixed Header
+ local header = make_header(packet_type.CONNACK, 0, variable_header:len())
+ return combine(header, variable_header)
+end
+
-- Create PUBLISH packet, DOC: 3.3 PUBLISH – Publish message
local function make_packet_publish(args)
-- check args
@@ -596,7 +642,7 @@ local function make_packet_subscribe(args)
for i, subscription in ipairs(args.subscriptions) do
assert(type(subscription) == "table", "expecting .subscriptions["..i.."] to be a table")
assert(type(subscription.topic) == "string", "expecting .subscriptions["..i.."].topic to be a string")
- if subscription.qos ~= nil then -- TODO: maybe remove that check and make .qos mandatory?
+ if subscription.qos ~= nil then
assert(type(subscription.qos) == "number", "expecting .subscriptions["..i.."].qos to be a number")
assert(check_qos(subscription.qos), "expecting .subscriptions["..i.."].qos to be a valid QoS value")
end
@@ -626,6 +672,30 @@ local function make_packet_subscribe(args)
return combine(header, variable_header, payload)
end
+-- Create SUBACK packet, DOC: 3.9 SUBACK – Subscribe acknowledgement
+local function make_packet_suback(args)
+ -- check args
+ assert(type(args.packet_id) == "number", "expecting .packet_id to be a number")
+ assert(check_packet_id(args.packet_id), "expecting .packet_id to be a valid Packet Identifier")
+ assert(type(args.rc) == "table", "expecting .rc to be a table")
+ assert(#args.rc > 0, "expecting .rc to be a non-empty array")
+ -- DOC: 3.9.2 SUBACK Variable Header
+ local variable_header = combine(
+ make_uint16(args.packet_id),
+ make_properties(packet_type.SUBACK, args) -- DOC: 3.9.2.1 SUBACK Properties
+ )
+ -- DOC: 3.9.3 SUBACK Payload
+ local payload = combine()
+ for i, rc in ipairs(args.rc) do
+ assert(type(rc) == "number", "expecting .rc["..i.."] to be a number")
+ assert(rc >= 0 and rc <= 255, "expecting .rc["..i.."] to be in range [0, 255]")
+ payload:append(make_uint8(rc))
+ end
+ -- DOC: 3.9.1 SUBACK Fixed Header
+ local header = make_header(packet_type.SUBACK, 0, variable_header:len() + payload:len()) -- NOTE: fixed flags value 0x0
+ return combine(header, variable_header, payload)
+end
+
-- Create UNSUBSCRIBE packet, DOC: 3.10 UNSUBSCRIBE – Unsubscribe request
local function make_packet_unsubscribe(args)
-- check args
@@ -649,6 +719,30 @@ local function make_packet_unsubscribe(args)
return combine(header, variable_header, payload)
end
+-- Create UNSUBACK packet, DOC: 3.11 UNSUBACK – Unsubscribe acknowledgement
+local function make_packet_unsuback(args)
+ -- check args
+ assert(type(args.packet_id) == "number", "expecting .packet_id to be a number")
+ assert(check_packet_id(args.packet_id), "expecting .packet_id to be a valid Packet Identifier")
+ assert(type(args.rc) == "table", "expecting .rc to be a table")
+ assert(#args.rc > 0, "expecting .rc to be a non-empty array")
+ -- DOC: 3.11.2 UNSUBACK Variable Header
+ local variable_header = combine(
+ make_uint16(args.packet_id),
+ make_properties(packet_type.UNSUBACK, args) -- DOC: 3.11.2.1 UNSUBACK Properties
+ )
+ -- DOC: 3.11.3 UNSUBACK Payload
+ local payload = combine()
+ for i, rc in ipairs(args.rc) do
+ assert(type(rc) == "number", "expecting .rc["..i.."] to be a number")
+ assert(rc >= 0 and rc <= 255, "expecting .rc["..i.."] to be in range [0, 255]")
+ payload:append(make_uint8(rc))
+ end
+ -- DOC: 3.11.1 UNSUBACK Fixed Header
+ local header = make_header(packet_type.UNSUBACK, 0, variable_header:len() + payload:len()) -- NOTE: fixed flags value 0x0
+ return combine(header, variable_header, payload)
+end
+
-- Create DISCONNECT packet, DOC: 3.14 DISCONNECT – Disconnect notification
local function make_packet_disconnect(args)
-- check args
@@ -688,28 +782,37 @@ function protocol5.make_packet(args)
assert(type(args) == "table", "expecting args to be a table")
assert(type(args.type) == "number", "expecting .type number in args")
local ptype = args.type
- if ptype == packet_type.CONNECT then
+ if ptype == packet_type.CONNECT then -- 1
return make_packet_connect(args)
- elseif ptype == packet_type.PUBLISH then
+ elseif ptype == packet_type.CONNACK then -- 3
+ return make_packet_connack(args)
+ elseif ptype == packet_type.PUBLISH then -- 3
return make_packet_publish(args)
- elseif ptype == packet_type.PUBACK then
+ elseif ptype == packet_type.PUBACK then -- 4
return make_packet_puback(args)
- elseif ptype == packet_type.PUBREC then
+ elseif ptype == packet_type.PUBREC then -- 5
return make_packet_pubrec(args)
- elseif ptype == packet_type.PUBREL then
+ elseif ptype == packet_type.PUBREL then -- 6
return make_packet_pubrel(args)
- elseif ptype == packet_type.PUBCOMP then
+ elseif ptype == packet_type.PUBCOMP then -- 7
return make_packet_pubcomp(args)
- elseif ptype == packet_type.SUBSCRIBE then
+ elseif ptype == packet_type.SUBSCRIBE then -- 8
return make_packet_subscribe(args)
- elseif ptype == packet_type.UNSUBSCRIBE then
+ elseif ptype == packet_type.SUBACK then -- 9
+ return make_packet_suback(args)
+ elseif ptype == packet_type.UNSUBSCRIBE then -- 10
return make_packet_unsubscribe(args)
- elseif ptype == packet_type.PINGREQ then
+ elseif ptype == packet_type.UNSUBACK then -- 11
+ return make_packet_unsuback(args)
+ elseif ptype == packet_type.PINGREQ then -- 12
-- DOC: 3.12 PINGREQ – PING request
return combine("\192\000") -- 192 == 0xC0, type == 12, flags == 0
- elseif ptype == packet_type.DISCONNECT then
+ elseif ptype == packet_type.PINGRESP then -- 12
+ -- DOC: 3.13 PINGRESP – PING response
+ return combine("\208\000") -- 208 == 0xD0, type == 13, flags == 0
+ elseif ptype == packet_type.DISCONNECT then -- 14
return make_packet_disconnect(args)
- elseif ptype == packet_type.AUTH then
+ elseif ptype == packet_type.AUTH then -- 15
return make_packet_auth(args)
else
error("unexpected packet type to make: "..ptype)
@@ -753,7 +856,9 @@ local function parse_properties(ptype, read_data, input, packet)
return false, "failed to parse property length: "..err
end
if not allowed[prop_id] then
- return false, "property "..prop_id.." is not allowed for packet type "..ptype
+ if not allowed[prop_id] then
+ return false, "property "..tostring(properties[prop_id]).." ("..prop_id..") is not allowed for that packet type"
+ end
end
if prop_id == uprop_id then
-- parse name=value string pair
@@ -787,8 +892,14 @@ local function parse_properties(ptype, read_data, input, packet)
local value
value, err = property_parse[prop_id](read_data)
if err then
- return false, "failed ro parse property "..prop_id.." value: "..err
+ return false, "failed to parse property "..prop_id.." value: "..err
end
+ if allowed[prop_id] ~= true then
+ if packet.properties[properties[prop_id]] ~= nil then
+ return false, "it is a Protocol Error to include the "..properties[prop_id].." ("..prop_id..") property more than once"
+ end
+ end
+ -- make an array of property values, if it's allowed to send multiple such properties
if property_multiple[prop_id] then
local curr = packet.properties[properties[prop_id]] or {}
curr[#curr + 1] = value
@@ -801,6 +912,536 @@ local function parse_properties(ptype, read_data, input, packet)
return true
end
+-- Parse CONNACK packet, DOC: 3.2 CONNACK – Connect acknowledgement
+local function parse_packet_connack(ptype, flags, input)
+ -- DOC: 3.2.1 CONNACK Fixed Header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ if input.available < 3 then
+ return false, packet_type[ptype]..": expecting data of length 3 bytes or more"
+ end
+ local read_data = input.read_func
+ -- DOC: 3.2.2 CONNACK Variable Header
+ -- DOC: 3.2.2.1.1 Session Present
+ -- DOC: 3.2.2.2 Connect Reason Code
+ local byte1, byte2 = parse_uint8(read_data), parse_uint8(read_data)
+ local sp = (band(byte1, 0x1) ~= 0)
+ local packet = setmetatable({type=ptype, sp=sp, rc=byte2}, connack_packet_mt)
+ -- DOC: 3.2.2.3 CONNACK Properties
+ local ok, err = parse_properties(ptype, read_data, input, packet)
+ if not ok then
+ return false, packet_type[ptype]..": failed to parse packet properties: "..err
+ end
+ return packet
+end
+
+-- Parse PUBLISH packet, DOC: 3.3 PUBLISH – Publish message
+local function parse_packet_publish(ptype, flags, input)
+ -- DOC: 3.3.1 PUBLISH Fixed Header
+ -- DOC: 3.3.1.1 DUP
+ local dup = (band(flags, 0x8) ~= 0)
+ -- DOC: 3.3.1.2 QoS
+ local qos = band(rshift(flags, 1), 0x3)
+ -- DOC: 3.3.1.3 RETAIN
+ local retain = (band(flags, 0x1) ~= 0)
+ -- DOC: 3.3.2 PUBLISH Variable Header
+ -- DOC: 3.3.2.1 Topic Name
+ local read_data = input.read_func
+ local topic, err = parse_string(read_data)
+ if not topic then
+ return false, packet_type[ptype]..": failed to parse topic: "..err
+ end
+ -- DOC: 3.3.2.2 Packet Identifier
+ local packet_id, ok
+ if qos > 0 then
+ packet_id, err = parse_uint16(read_data)
+ if not packet_id then
+ return false, packet_type[ptype]..": failed to parse packet_id: "..err
+ end
+ end
+ -- DOC: 3.3.2.3 PUBLISH Properties
+ local packet = setmetatable({type=ptype, dup=dup, qos=qos, retain=retain, packet_id=packet_id, topic=topic}, packet_mt)
+ ok, err = parse_properties(ptype, read_data, input, packet)
+ if not ok then
+ return false, packet_type[ptype]..": failed to parse packet properties: "..err
+ end
+ if input.available > 0 then
+ -- DOC: 3.3.3 PUBLISH Payload
+ packet.payload = read_data(input.available)
+ end
+ return packet
+end
+
+-- Parse PUBACK packet, DOC: 3.4 PUBACK – Publish acknowledgement
+local function parse_packet_puback(ptype, flags, input)
+ -- DOC: 3.4.1 PUBACK Fixed Header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ local read_data = input.read_func
+ -- DOC: 3.4.2 PUBACK Variable Header
+ local packet_id, err = parse_uint16(read_data)
+ if not packet_id then
+ return false, packet_type[ptype]..": failed to parse packet_id: "..err
+ end
+ local packet = setmetatable({type=ptype, packet_id=packet_id, rc=0, properties={}, user_properties={}}, packet_mt)
+ if input.available > 0 then
+ -- DOC: 3.4.2.1 PUBACK Reason Code
+ local rc, ok
+ rc, err = parse_uint8(read_data)
+ if not rc then
+ return false, packet_type[ptype]..": failed to parse rc: "..err
+ end
+ packet.rc = rc
+ -- DOC: 3.4.2.2 PUBACK Properties
+ ok, err = parse_properties(ptype, read_data, input, packet)
+ if not ok then
+ return false, packet_type[ptype]..": failed to parse packet properties: "..err
+ end
+ end
+ return packet
+end
+
+-- Parse PUBREC packet, DOC: 3.5 PUBREC – Publish received (QoS 2 delivery part 1)
+local function parse_packet_pubrec(ptype, flags, input)
+ -- DOC: 3.5.1 PUBREC Fixed Header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ local read_data = input.read_func
+ -- DOC: 3.5.2 PUBREC Variable Header
+ local packet_id, err = parse_uint16(read_data)
+ if not packet_id then
+ return false, packet_type[ptype]..": failed to parse packet_id: "..err
+ end
+ local packet = setmetatable({type=ptype, packet_id=packet_id, rc=0, properties={}, user_properties={}}, packet_mt)
+ if input.available > 0 then
+ -- DOC: 3.5.2.1 PUBREC Reason Code
+ local rc
+ rc, err = parse_uint8(read_data)
+ if not rc then
+ return false, packet_type[ptype]..": failed to parse rc: "..err
+ end
+ packet.rc = rc
+ -- DOC: 3.5.2.2 PUBREC Properties
+ local ok
+ ok, err = parse_properties(ptype, read_data, input, packet)
+ if not ok then
+ return false, packet_type[ptype]..": failed to parse packet properties: "..err
+ end
+ end
+ return packet
+end
+
+-- Parse PUBREL packet, DOC: 3.6 PUBREL – Publish release (QoS 2 delivery part 2)
+local function parse_packet_pubrel(ptype, flags, input)
+ -- DOC: 3.6.1 PUBREL Fixed Header
+ if flags ~= 2 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ local read_data = input.read_func
+ -- DOC: 3.6.2 PUBREL Variable Header
+ local packet_id, err = parse_uint16(read_data)
+ if not packet_id then
+ return false, packet_type[ptype]..": failed to parse packet_id: "..err
+ end
+ local packet = setmetatable({type=ptype, packet_id=packet_id, rc=0, properties={}, user_properties={}}, packet_mt)
+ if input.available > 0 then
+ -- DOC: 3.6.2.1 PUBREL Reason Code
+ local rc
+ rc, err = parse_uint8(read_data)
+ if not rc then
+ return false, packet_type[ptype]..": failed to parse rc: "..err
+ end
+ packet.rc = rc
+ -- DOC: 3.6.2.2 PUBREL Properties
+ local ok
+ ok, err = parse_properties(ptype, read_data, input, packet)
+ if not ok then
+ return false, packet_type[ptype]..": failed to parse packet properties: "..err
+ end
+ end
+ return packet
+end
+
+-- Parse PUBCOMP packet, DOC: 3.7 PUBCOMP – Publish complete (QoS 2 delivery part 3)
+local function parse_packet_pubcomp(ptype, flags, input)
+ -- DOC: 3.7.1 PUBCOMP Fixed Header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ local read_data = input.read_func
+ -- DOC: 3.7.2 PUBCOMP Variable Header
+ local packet_id, err = parse_uint16(read_data)
+ if not packet_id then
+ return false, packet_type[ptype]..": failed to parse packet_id: "..err
+ end
+ local packet = setmetatable({type=ptype, packet_id=packet_id, rc=0, properties={}, user_properties={}}, packet_mt)
+ if input.available > 0 then
+ -- DOC: 3.7.2.1 PUBCOMP Reason Code
+ local rc
+ rc, err = parse_uint8(read_data)
+ if not rc then
+ return false, packet_type[ptype]..": failed to parse rc: "..err
+ end
+ packet.rc = rc
+ -- DOC: 3.7.2.2 PUBCOMP Properties
+ local ok
+ ok, err = parse_properties(ptype, read_data, input, packet)
+ if not ok then
+ return false, packet_type[ptype]..": failed to parse packet properties: "..err
+ end
+ end
+ return packet
+end
+
+-- Parse SUBSCRIBE packet, DOC: 3.8 SUBSCRIBE - Subscribe request
+local function parse_packet_subscribe(ptype, flags, input)
+ -- DOC: 3.8.1 SUBSCRIBE Fixed Header
+ if flags ~= 2 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ local read_data = input.read_func
+ -- DOC: 3.8.2 SUBSCRIBE Variable Header
+ local packet_id, err = parse_uint16(read_data)
+ if not packet_id then
+ return false, packet_type[ptype]..": failed to parse packet_id: "..err
+ end
+ local packet = setmetatable({type=ptype, packet_id=packet_id, properties={}, user_properties={}}, packet_mt)
+ -- DOC: 3.8.2.1 SUBSCRIBE Properties
+ local ok
+ ok, err = parse_properties(ptype, read_data, input, packet)
+ if not ok then
+ return false, packet_type[ptype]..": failed to parse packet properties: "..err
+ end
+ -- DOC: 3.8.3 SUBSCRIBE Payload
+ if input.available == 0 then
+ -- DOC: A SUBSCRIBE packet with no Payload is a Protocol Error.
+ return false, packet_type[ptype]..": empty subscriptions list"
+ end
+ local subscriptions = {}
+ while input.available > 0 do
+ local topic_filter
+ topic_filter, err = parse_string(input.read_func)
+ if not topic_filter then
+ return false, packet_type[ptype]..": failed to parse SUBSCRIBE topic filter: "..err
+ end
+ -- DOC: 3.8.3.1 Subscription Options
+ local subscription_options
+ subscription_options, err = parse_uint8(input.read_func)
+ if not subscription_options then
+ return false, packet_type[ptype]..": failed to parse subscription_options: "..err
+ end
+ subscriptions[#subscriptions + 1] = {
+ topic = topic_filter,
+ qos = band(subscription_options, 0x3),
+ no_local = band(subscription_options, 0x4) ~= 0,
+ retain_as_published = band(subscription_options, 0x8) ~= 0, -- Retain As Published
+ retain_handling = band(rshift(subscription_options, 4), 0x3), -- Retain Handling
+ }
+ end
+ packet.subscriptions = subscriptions
+ return packet
+end
+
+-- SUBACK return codes/reason code strings
+-- DOC: Table 3‑8 - Subscribe Reason Codes
+local suback_rc = {
+ [0x00] = "Granted QoS 0",
+ [0x01] = "Granted QoS 1",
+ [0x02] = "Granted QoS 2",
+ [0x80] = "Unspecified error",
+ [0x83] = "Implementation specific error",
+ [0x87] = "Not authorized",
+ [0x8F] = "Topic Filter invalid",
+ [0x91] = "Packet Identifier in use",
+ [0x97] = "Quota exceeded",
+ [0x9E] = "Shared Subscriptions not supported",
+ [0xA1] = "Subscription Identifiers not supported",
+ [0xA2] = "Wildcard Subscriptions not supported",
+}
+protocol5.suback_rc = suback_rc
+
+--- Parsed SUBACK packet metatable
+local suback_packet_mt = {
+ __tostring = protocol.packet_tostring, -- packet-to-human-readable-string conversion metamethod using protocol.packet_tostring()
+ reason_strings = function(self) -- Returns reason strings for the SUBACK packet according to its rc field
+ local human_readable = {}
+ for i, rc in ipairs(self.rc) do
+ local reason_string = suback_rc[rc]
+ if reason_string then
+ human_readable[i] = reason_string
+ else
+ human_readable[i] = "Unknown: "..tostring(rc)
+ end
+ end
+ return human_readable
+ end,
+}
+suback_packet_mt.__index = suback_packet_mt
+protocol5.suback_packet_mt = suback_packet_mt
+
+-- Parse SUBACK packet, DOC: 3.9 SUBACK – Subscribe acknowledgement
+local function parse_packet_suback(ptype, flags, input)
+ -- DOC: 3.9.1 SUBACK Fixed Header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ local read_data = input.read_func
+ -- DOC: 3.9.2 SUBACK Variable Header
+ local packet_id, err = parse_uint16(read_data)
+ if not packet_id then
+ return false, packet_type[ptype]..": failed to parse packet_id: "..err
+ end
+ -- DOC: 3.9.2.1 SUBACK Properties
+ local packet = setmetatable({type=ptype, packet_id=packet_id}, suback_packet_mt)
+ local ok
+ ok, err = parse_properties(ptype, read_data, input, packet)
+ if not ok then
+ return false, packet_type[ptype]..": failed to parse packet properties: "..err
+ end
+ -- DOC: 3.9.3 SUBACK Payload
+ local rcs = {}
+ while input.available > 0 do
+ local rc
+ rc, err = parse_uint8(read_data)
+ if not rc then
+ return false, packet_type[ptype]..": failed to parse reason code: "..err
+ end
+ rcs[#rcs + 1] = rc
+ end
+ if not next(rcs) then
+ return false, packet_type[ptype]..": expecting at least one reason code"
+ end
+ packet.rc = rcs
+ return packet
+end
+
+-- Parse UNSUBSCRIBE packet, DOC: 3.10 UNSUBSCRIBE – Unsubscribe request
+local function parse_packet_unsubscribe(ptype, flags, input)
+ -- DOC: 3.10.1 UNSUBSCRIBE Fixed Header
+ if flags ~= 2 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ local read_data = input.read_func
+ -- DOC: 3.10.2 UNSUBSCRIBE Variable Header
+ local packet_id, err = parse_uint16(read_data)
+ if not packet_id then
+ return false, packet_type[ptype]..": failed to parse packet_id: "..err
+ end
+ local packet = setmetatable({type=ptype, packet_id=packet_id, properties={}, user_properties={}}, packet_mt)
+ -- DOC: 3.10.2.1 UNSUBSCRIBE Properties
+ local ok
+ ok, err = parse_properties(ptype, read_data, input, packet)
+ if not ok then
+ return false, packet_type[ptype]..": failed to parse packet properties: "..err
+ end
+ -- 3.10.3 UNSUBSCRIBE Payload
+ -- DOC: An UNSUBSCRIBE packet with no Payload is a Protocol Error.
+ if input.available == 0 then
+ return false, packet_type[ptype]..": empty subscriptions list"
+ end
+ local subscriptions = {}
+ while input.available > 0 do
+ local topic_filter
+ topic_filter, err = parse_string(input.read_func)
+ if not topic_filter then
+ return false, packet_type[ptype]..": failed to parse topic filter: "..err
+ end
+ subscriptions[#subscriptions + 1] = topic_filter
+ end
+ packet.subscriptions = subscriptions
+ return packet
+end
+
+-- UNSUBACK Reason Codes
+-- DOC[2]: Table 3‑9 - Unsubscribe Reason Codes
+local unsuback_rc = {
+ [0x00] = "Success",
+ [0x11] = "No subscription existed",
+ [0x80] = "Unspecified error",
+ [0x83] = "Implementation specific error",
+ [0x87] = "Not authorized",
+ [0x8F] = "Topic Filter invalid",
+ [0x91] = "Packet Identifier in use",
+}
+protocol5.unsuback_rc = unsuback_rc
+
+--- Parsed UNSUBACK packet metatable
+local unsuback_packet_mt = {
+ __tostring = protocol.packet_tostring, -- packet-to-human-readable-string conversion metamethod using protocol.packet_tostring()
+ reason_strings = function(self) -- Returns reason strings for the UNSUBACK packet according to its rc field
+ local human_readable = {}
+ for i, rc in ipairs(self.rc) do
+ local reason_string = unsuback_rc[rc]
+ if reason_string then
+ human_readable[i] = reason_string
+ else
+ human_readable[i] = "Unknown: "..tostring(rc)
+ end
+ end
+ return human_readable
+ end,
+}
+unsuback_packet_mt.__index = unsuback_packet_mt
+protocol5.unsuback_packet_mt = unsuback_packet_mt
+
+-- Parse UNSUBACK packet, DOC: 3.11 UNSUBACK – Unsubscribe acknowledgement
+local function parse_packet_unsuback(ptype, flags, input)
+ -- DOC: 3.11.1 UNSUBACK Fixed Header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ local read_data = input.read_func
+ -- DOC: 3.11.2 UNSUBACK Variable Header
+ local packet_id, err = parse_uint16(read_data)
+ if not packet_id then
+ return false, packet_type[ptype]..": failed to parse packet_id: "..err
+ end
+ -- 3.11.2.1 UNSUBACK Properties
+ local packet = setmetatable({type=ptype, packet_id=packet_id}, unsuback_packet_mt)
+ local ok
+ ok, err = parse_properties(ptype, read_data, input, packet)
+ if not ok then
+ return false, packet_type[ptype]..": failed to parse packet properties: "..err
+ end
+ -- 3.11.3 UNSUBACK Payload
+ local rcs = {}
+ while input.available > 0 do
+ local rc
+ rc, err = parse_uint8(read_data)
+ if not rc then
+ return false, packet_type[ptype]..": failed to parse reason code: "..err
+ end
+ rcs[#rcs + 1] = rc
+ end
+ if not next(rcs) then
+ return false, packet_type[ptype]..": expecting at least one reason code in"
+ end
+ packet.rc = rcs
+ return packet
+end
+
+-- Parse PINGREQ packet, DOC: 3.12 PINGREQ – PING request
+local function parse_packet_pingreq(ptype, flags, _)
+ -- DOC: 3.12.1 PINGREQ Fixed Header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ return setmetatable({type=ptype, properties={}, user_properties={}}, packet_mt)
+end
+
+-- Parse PINGRESP packet, DOC: 3.13 PINGRESP – PING response
+local function parse_packet_pingresp(ptype, flags, _)
+ -- DOC: 3.13.1 PINGRESP Fixed Header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ return setmetatable({type=ptype, properties={}, user_properties={}}, packet_mt)
+end
+
+-- DISCONNECT reason codes
+-- DOC: Table 3‑10 – Disconnect Reason Code values
+local disconnect_rc = {
+ [0x00] = "Normal disconnection",
+ [0x04] = "Disconnect with Will Message",
+ [0x80] = "Unspecified error",
+ [0x81] = "Malformed Packet",
+ [0x82] = "Protocol Error",
+ [0x83] = "Implementation specific error",
+ [0x87] = "Not authorized",
+ [0x89] = "Server busy",
+ [0x8B] = "Server shutting down",
+ [0x8D] = "Keep Alive timeout",
+ [0x8E] = "Session taken over",
+ [0x8F] = "Topic Filter invalid",
+ [0x90] = "Topic Name invalid",
+ [0x93] = "Receive Maximum exceeded",
+ [0x94] = "Topic Alias invalid",
+ [0x95] = "Packet too large",
+ [0x96] = "Message rate too high",
+ [0x97] = "Quota exceeded",
+ [0x98] = "Administrative action",
+ [0x99] = "Payload format invalid",
+ [0x9A] = "Retain not supported",
+ [0x9B] = "QoS not supported",
+ [0x9C] = "Use another server",
+ [0x9D] = "Server moved",
+ [0x9E] = "Shared Subscriptions not supported",
+ [0x9F] = "Connection rate exceeded",
+ [0xA0] = "Maximum connect time",
+ [0xA1] = "Subscription Identifiers not supported",
+ [0xA2] = "Wildcard Subscriptions not supported",
+}
+protocol5.disconnect_rc = disconnect_rc
+
+--- Parsed DISCONNECT packet metatable
+local disconnect_packet_mt = {
+ __tostring = protocol.packet_tostring, -- packet-to-human-readable-string conversion metamethod using protocol.packet_tostring()
+ reason_string = function(self) -- Returns reason string for the DISCONNECT packet according to its rc field
+ local reason_string = disconnect_rc[self.rc]
+ if not reason_string then
+ reason_string = "Unknown: "..self.rc
+ end
+ return reason_string
+ end,
+}
+disconnect_packet_mt.__index = disconnect_packet_mt
+protocol5.disconnect_packet_mt = disconnect_packet_mt
+
+-- Parse DISCONNECT packet, DOC: 3.14 DISCONNECT – Disconnect notification
+local function parse_packet_disconnect(ptype, flags, input)
+ -- DOC: 3.14.1 DISCONNECT Fixed Header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ local read_data = input.read_func
+ local packet = setmetatable({type=ptype, rc=0, properties={}, user_properties={}}, disconnect_packet_mt)
+ if input.available > 0 then
+ -- DOC: 3.14.2 DISCONNECT Variable Header
+ -- DOC: 3.14.2.1 Disconnect Reason Code
+ local rc, err = parse_uint8(read_data)
+ if not rc then
+ return false, packet_type[ptype]..": failed to parse rc: "..err
+ end
+ packet.rc = rc
+ -- DOC: 3.14.2.2 DISCONNECT Properties
+ local ok
+ ok, err = parse_properties(ptype, read_data, input, packet)
+ if not ok then
+ return false, packet_type[ptype]..": failed to parse packet properties: "..err
+ end
+ end
+ return packet
+end
+
+-- Parse AUTH packet, DOC: 3.15 AUTH – Authentication exchange
+local function parse_packet_auth(ptype, flags, input)
+ -- DOC: 3.15.1 AUTH Fixed Header
+ if flags ~= 0 then -- Reserved
+ return false, packet_type[ptype]..": unexpected flags value: "..flags
+ end
+ local read_data = input.read_func
+ -- DOC: 3.15.2.1 Authenticate Reason Code
+ local packet = setmetatable({type=ptype, rc=0, properties={}, user_properties={}}, packet_mt)
+ if input.available > 1 then
+ -- DOC: 3.15.2 AUTH Variable Header
+ local rc, err = parse_uint8(read_data)
+ if not rc then
+ return false, packet_type[ptype]..": failed to parse Authenticate Reason Code: "..err
+ end
+ packet.rc = rc
+ -- DOC: 3.15.2.2 AUTH Properties
+ local ok
+ ok, err = parse_properties(ptype, read_data, input, packet)
+ if not ok then
+ return false, packet_type[ptype]..": failed to parse packet properties: "..err
+ end
+ end
+ return packet
+end
+
-- Parse packet using given read_func
-- Returns packet on success or false and error message on failure
function protocol5.parse_packet(read_func)
@@ -808,229 +1449,119 @@ function protocol5.parse_packet(read_func)
if not ptype then
return false, flags
end
- local byte1, byte2, err, rc, ok, packet, topic, packet_id
- local read_data = input.read_func
+ local packet, err
- -- parse readed data according type in fixed header
- if ptype == packet_type.CONNACK then
- -- DOC: 3.2 CONNACK – Connect acknowledgement
- if input.available < 3 then
- return false, "expecting data of length 3 bytes or more"
- end
- -- DOC: 3.2.2.1.1 Session Present
- -- DOC: 3.2.2.2 Connect Reason Code
- byte1, byte2 = parse_uint8(read_data), parse_uint8(read_data)
- local sp = (band(byte1, 0x1) ~= 0)
- packet = setmetatable({type=ptype, sp=sp, rc=byte2}, connack_packet_mt)
- -- DOC: 3.2.2.3 CONNACK Properties
- ok, err = parse_properties(ptype, read_data, input, packet)
- if not ok then
- return false, "failed to parse packet properties: "..err
- end
- elseif ptype == packet_type.PUBLISH then
- -- DOC: 3.3 PUBLISH – Publish message
- -- DOC: 3.3.1.1 DUP
- local dup = (band(flags, 0x8) ~= 0)
- -- DOC: 3.3.1.2 QoS
- local qos = band(rshift(flags, 1), 0x3)
- -- DOC: 3.3.1.3 RETAIN
- local retain = (band(flags, 0x1) ~= 0)
- -- DOC: 3.3.2.1 Topic Name
- topic, err = parse_string(read_data)
- if not topic then
- return false, "failed to parse topic: "..err
- end
- -- DOC: 3.3.2.2 Packet Identifier
- if qos > 0 then
- packet_id, err = parse_uint16(read_data)
- if not packet_id then
- return false, "failed to parse packet_id: "..err
- end
- end
- -- DOC: 3.3.2.3 PUBLISH Properties
- packet = setmetatable({type=ptype, dup=dup, qos=qos, retain=retain, packet_id=packet_id, topic=topic}, packet_mt)
- ok, err = parse_properties(ptype, read_data, input, packet)
- if not ok then
- return false, "failed to parse packet properties: "..err
- end
- if input.available > 0 then
- -- DOC: 3.3.3 PUBLISH Payload
- packet.payload = read_data(input.available)
- end
- elseif ptype == packet_type.PUBACK then
- -- DOC: 3.4 PUBACK – Publish acknowledgement
- packet_id, err = parse_uint16(read_data)
- if not packet_id then
- return false, "failed to parse packet_id: "..err
- end
- packet = setmetatable({type=ptype, packet_id=packet_id, rc=0, properties={}, user_properties={}}, packet_mt)
- if input.available > 0 then
- -- DOC: 3.4.2.1 PUBACK Reason Code
- rc, err = parse_uint8(read_data)
- if not rc then
- return false, "failed to parse rc: "..err
- end
- packet.rc = rc
- -- DOC: 3.4.2.2 PUBACK Properties
- ok, err = parse_properties(ptype, read_data, input, packet)
- if not ok then
- return false, "failed to parse packet properties: "..err
- end
- end
- elseif ptype == packet_type.PUBREC then
- -- DOC: 3.5 PUBREC – Publish received (QoS 2 delivery part 1)
- packet_id, err = parse_uint16(read_data)
- if not packet_id then
- return false, "failed to parse packet_id: "..err
- end
- packet = setmetatable({type=ptype, packet_id=packet_id, rc=0, properties={}, user_properties={}}, packet_mt)
- if input.available > 0 then
- -- DOC: 3.5.2.1 PUBREC Reason Code
- rc, err = parse_uint8(read_data)
- if not rc then
- return false, "failed to parse rc: "..err
- end
- packet.rc = rc
- -- DOC: 3.5.2.2 PUBREC Properties
- ok, err = parse_properties(ptype, read_data, input, packet)
- if not ok then
- return false, "failed to parse packet properties: "..err
- end
- end
- elseif ptype == packet_type.PUBREL then
- -- DOC: 3.6 PUBREL – Publish release (QoS 2 delivery part 2)
- packet_id, err = parse_uint16(read_data)
- if not packet_id then
- return false, "failed to parse packet_id: "..err
- end
- packet = setmetatable({type=ptype, packet_id=packet_id, rc=0, properties={}, user_properties={}}, packet_mt)
- if input.available > 0 then
- -- DOC: 3.6.2.1 PUBREL Reason Code
- rc, err = parse_uint8(read_data)
- if not rc then
- return false, "failed to parse rc: "..err
- end
- packet.rc = rc
- -- DOC: 3.6.2.2 PUBREL Properties
- ok, err = parse_properties(ptype, read_data, input, packet)
- if not ok then
- return false, "failed to parse packet properties: "..err
- end
- end
- elseif ptype == packet_type.PUBCOMP then
- -- DOC: 3.7 PUBCOMP – Publish complete (QoS 2 delivery part 3)
- packet_id, err = parse_uint16(read_data)
- if not packet_id then
- return false, "failed to parse packet_id: "..err
- end
- packet = setmetatable({type=ptype, packet_id=packet_id, rc=0, properties={}, user_properties={}}, packet_mt)
- if input.available > 0 then
- -- DOC: 3.7.2.1 PUBCOMP Reason Code
- rc, err = parse_uint8(read_data)
- if not rc then
- return false, "failed to parse rc: "..err
- end
- packet.rc = rc
- -- DOC: 3.7.2.2 PUBCOMP Properties
- ok, err = parse_properties(ptype, read_data, input, packet)
- if not ok then
- return false, "failed to parse packet properties: "..err
- end
- end
- elseif ptype == packet_type.SUBACK then
- -- DOC: 3.9 SUBACK – Subscribe acknowledgement
- -- DOC: 3.9.2 SUBACK Variable Header
- packet_id, err = parse_uint16(read_data)
- if not packet_id then
- return false, "failed to parse packet_id: "..err
- end
- -- DOC: 3.9.2.1 SUBACK Properties
- packet = setmetatable({type=ptype, packet_id=packet_id}, packet_mt)
- ok, err = parse_properties(ptype, read_data, input, packet)
- if not ok then
- return false, "failed to parse packet properties: "..err
- end
- -- DOC: 3.9.3 SUBACK Payload
- local rcs = {}
- while input.available > 0 do
- rc, err = parse_uint8(read_data)
- if not rc then
- return false, "failed to parse reason code: "..err
- end
- rcs[#rcs + 1] = rc
- end
- if not next(rcs) then
- return false, "expecting at least one reason code in SUBACK"
- end
- packet.rc = rcs -- TODO: reason codes table somewhere should be placed
- elseif ptype == packet_type.UNSUBACK then
- -- DOC: 3.11 UNSUBACK – Unsubscribe acknowledgement
- -- DOC: 3.11.2 UNSUBACK Variable Header
- packet_id, err = parse_uint16(read_data)
- if not packet_id then
- return false, "failed to parse packet_id: "..err
- end
- -- 3.11.2.1 UNSUBACK Properties
- packet = setmetatable({type=ptype, packet_id=packet_id}, packet_mt)
- ok, err = parse_properties(ptype, read_data, input, packet)
- if not ok then
- return false, "failed to parse packet properties: "..err
- end
- -- 3.11.3 UNSUBACK Payload
- local rcs = {}
- while input.available > 0 do
- rc, err = parse_uint8(read_data)
- if not rc then
- return false, "failed to parse reason code: "..err
- end
- rcs[#rcs + 1] = rc
- end
- if not next(rcs) then
- return false, "expecting at least one reason code in UNSUBACK"
- end
- packet.rc = rcs
- elseif ptype == packet_type.PINGRESP then
- -- DOC: 3.13 PINGRESP – PING response
- packet = setmetatable({type=ptype, properties={}, user_properties={}}, packet_mt)
- elseif ptype == packet_type.DISCONNECT then
- -- DOC: 3.14 DISCONNECT – Disconnect notification
- packet = setmetatable({type=ptype, rc=0, properties={}, user_properties={}}, packet_mt)
- if input.available > 0 then
- -- DOC: 3.14.2.1 Disconnect Reason Code
- rc, err = parse_uint8(read_data) -- TODO: reason codes table
- if not rc then
- return false, "failed to parse rc: "..err
- end
- packet.rc = rc
- -- DOC: 3.14.2.2 DISCONNECT Properties
- ok, err = parse_properties(ptype, read_data, input, packet)
- if not ok then
- return false, "failed to parse packet properties: "..err
- end
- end
- elseif ptype == packet_type.AUTH then
- -- DOC: 3.15 AUTH – Authentication exchange
- -- DOC: 3.15.2.1 Authenticate Reason Code
- packet = setmetatable({type=ptype, rc=0, properties={}, user_properties={}}, packet_mt)
- if input.available > 1 then
- rc, err = parse_uint8(read_data)
- if not rc then
- return false, "failed to parse Authenticate Reason Code: "..err
- end
- packet.rc = rc
- -- DOC: 3.15.2.2 AUTH Properties
- ok, err = parse_properties(ptype, read_data, input, packet)
- if not ok then
- return false, "failed to parse packet properties: "..err
- end
- end
+ -- parse read data according type in fixed header
+ if ptype == packet_type.CONNECT then -- 1
+ packet, err = parse_packet_connect_input(input, const_v50)
+ elseif ptype == packet_type.CONNACK then -- 2
+ packet, err = parse_packet_connack(ptype, flags, input)
+ elseif ptype == packet_type.PUBLISH then -- 3
+ packet, err = parse_packet_publish(ptype, flags, input)
+ elseif ptype == packet_type.PUBACK then -- 4
+ packet, err = parse_packet_puback(ptype, flags, input)
+ elseif ptype == packet_type.PUBREC then -- 5
+ packet, err = parse_packet_pubrec(ptype, flags, input)
+ elseif ptype == packet_type.PUBREL then -- 6
+ packet, err = parse_packet_pubrel(ptype, flags, input)
+ elseif ptype == packet_type.PUBCOMP then -- 7
+ packet, err = parse_packet_pubcomp(ptype, flags, input)
+ elseif ptype == packet_type.SUBSCRIBE then -- 8
+ packet, err = parse_packet_subscribe(ptype, flags, input)
+ elseif ptype == packet_type.SUBACK then -- 9
+ packet, err = parse_packet_suback(ptype, flags, input)
+ elseif ptype == packet_type.UNSUBSCRIBE then -- 10
+ packet, err = parse_packet_unsubscribe(ptype, flags, input)
+ elseif ptype == packet_type.UNSUBACK then -- 11
+ packet, err = parse_packet_unsuback(ptype, flags, input)
+ elseif ptype == packet_type.PINGREQ then -- 12
+ packet, err = parse_packet_pingreq(ptype, flags, input)
+ elseif ptype == packet_type.PINGRESP then -- 13
+ packet, err = parse_packet_pingresp(ptype, flags, input)
+ elseif ptype == packet_type.DISCONNECT then -- 14
+ packet, err = parse_packet_disconnect(ptype, flags, input)
+ elseif ptype == packet_type.AUTH then -- 15
+ packet, err = parse_packet_auth(ptype, flags, input)
else
return false, "unexpected packet type received: "..tostring(ptype)
end
- if input.available > 0 then
- return false, "extra data in remaining length left after packet parsing"
+ if packet and input.available > 0 then
+ return false, packet_type[ptype]..": extra data in remaining length left after packet parsing"
end
- return packet
+ return packet, err
+end
+
+-- Continue parsing of the MQTT v5.0 CONNECT packet
+-- Internally called from the protocol.parse_packet_connect_input() function
+-- Returns packet on success or false and error message on failure
+function protocol5._parse_packet_connect_continue(input, packet)
+ local read_func = input.read_func
+ local ok, client_id, err
+
+ -- DOC: 3.1.2.11 CONNECT Properties
+ ok, err = parse_properties(packet_type.CONNECT, read_func, input, packet)
+ if not ok then
+ return false, "CONNECT: failed to parse packet properties: "..err
+ end
+
+ -- DOC: 3.1.3 CONNECT Payload
+
+ -- DOC: 3.1.3.1 Client Identifier (ClientID)
+ client_id, err = parse_string(read_func)
+ if not client_id then
+ return false, "CONNECT: failed to parse client_id: "..err
+ end
+ packet.id = client_id
+
+ local will = packet.will
+ if will then
+ -- DOC: 3.1.3.2 Will Properties
+ ok, err = parse_properties("will", read_func, input, will)
+ if not ok then
+ return false, "CONNECT: failed to parse will message properties: "..err
+ end
+
+ -- DOC: 3.1.3.3 Will Topic
+ local will_topic, will_payload
+ will_topic, err = parse_string(read_func)
+ if not will_topic then
+ return false, "CONNECT: failed to parse will_topic: "..err
+ end
+ will.topic = will_topic
+
+ -- DOC: 3.1.3.4 Will Payload
+ will_payload, err = parse_string(read_func)
+ if not will_payload then
+ return false, "CONNECT: failed to parse will_payload: "..err
+ end
+ will.payload = will_payload
+ end
+
+ -- DOC: 3.1.3.5 User Name
+ if packet.username then
+ local username
+ username, err = parse_string(read_func)
+ if not username then
+ return false, "CONNECT: failed to parse username: "..err
+ end
+ packet.username = username
+ else
+ packet.username = nil
+ end
+
+ -- DOC: 3.1.3.6 Password
+ if packet.password then
+ local password
+ password, err = parse_string(read_func)
+ if not password then
+ return false, "CONNECT: failed to parse password: "..err
+ end
+ packet.password = password
+ else
+ packet.password = nil
+ end
+
+ return setmetatable(packet, packet_mt)
end
-- export module table
diff --git a/controller-host/mqtt/tools.lua b/controller-host/mqtt/tools.lua
index 48b8886..1867b81 100644
--- a/controller-host/mqtt/tools.lua
+++ b/controller-host/mqtt/tools.lua
@@ -10,6 +10,11 @@ local str_byte = string.byte
local table = require("table")
local tbl_concat = table.concat
+local tbl_sort = table.sort
+
+local type = type
+local error = error
+local pairs = pairs
local math = require("math")
local math_floor = math.floor
@@ -29,6 +34,73 @@ function tools.div(x, y)
return math_floor(x / y)
end
+-- table.sort callback for tools.sortedpairs()
+local function sortedpairs_compare(a, b)
+ local a_type = type(a)
+ local b_type = type(b)
+ if (a_type == "string" and b_type == "string") or (a_type == "number" and b_type == "number") then
+ return a < b
+ elseif a_type == "number" then
+ return true
+ elseif b_type == "number" then
+ return false
+ else
+ error("sortedpairs failed to make a stable keys comparison of types "..a_type.." and "..b_type)
+ end
+end
+
+-- Iterate through table keys and values in stable (sorted) order
+function tools.sortedpairs(tbl)
+ local keys = {}
+ for k in pairs(tbl) do
+ local k_type = type(k)
+ if k_type ~= "string" and k_type ~= "number" then
+ error("sortedpairs failed to make a stable iteration order for key of type "..type(k))
+ end
+ keys[#keys + 1] = k
+ end
+ tbl_sort(keys, sortedpairs_compare)
+ local i = 0
+ return function()
+ i = i + 1
+ local key = keys[i]
+ if key then
+ return key, tbl[key]
+ end
+ end
+end
+
+-- Converts multi-line string to a HEX string, removing all whitespace and line-comments started with "--"
+function tools.extract_hex(str)
+ local res = {}
+ -- iterate through lines
+ local n = 0
+ for line in str:gmatch("[^\n]+") do
+ n = n + 1
+ -- find a comment start
+ local comment_begin = line:find("--", 1, true)
+ if comment_begin then
+ -- remove comment from the line
+ line = line:sub(1, comment_begin - 1)
+ end
+ -- remove all whitespace from the line
+ line = line:gsub("%s", "")
+ -- check for the non-hex chars
+ local non_hex = line:find("[^0-9A-Fa-f]+")
+ if non_hex then
+ error(string.format("non-hex char '%s' at %s:%s", line:sub(non_hex, non_hex), n, non_hex))
+ end
+ -- append line to concat list
+ res[#res + 1] = line
+ end
+ -- finally concat all lines onto one HEX-string
+ local hexstr = tbl_concat(res)
+ if (#hexstr % 2) ~= 0 then
+ error("odd number of chars in the resulting HEX string")
+ end
+ return hexstr
+end
+
-- export module table
return tools
diff --git a/controller-host/mymqtt.lua b/controller-host/mymqtt.lua
deleted file mode 100644
index 627aa59..0000000
--- a/controller-host/mymqtt.lua
+++ /dev/null
@@ -1,359 +0,0 @@
-local mqtt = {}
-local socket = require("socket")
-
-local clientMt = {}
-
-local function bytesUsedByVarIntForValue(value)
- if value <= 128-1 then
- return 1, nil
- elseif value <= 128*128-1 then
- return 2, nil
- elseif value <= 128*128*128-1 then
- return 3, nil
- elseif value <= 128*128*128*128-1 then
- return 4, nil
- else
- return nil, "invalid byte length"
- end
-end
-
-local function bytesUsedByString(string)
- return 2 + #string
-end
-
-function clientMt:receiveByte()
-
-end
-
-function clientMt:flush()
- local start = 1
- while start <= #self.buffer do
- print("flushing data")
- local _, err, sent = self.connection:send(self.buffer, start)
- if err then
- print("Error: " .. err)
- return nil, err
- else
- self.buffer = ""
- return true, err
- end
- end
- return true, nil
-end
-
-function clientMt:sendByte(byte)
- self.buffer = self.buffer .. string.char(byte)
- if #self.buffer > 1024 then
- return self:flush()
- end
-end
-
-function clientMt:sendData(data)
- local result, err = self:flush()
- if err then return result, err end
-
- self.buffer = data
-
- self:flush()
- local result, err = self:flush()
- if err then return result, err end
-end
-
-function clientMt:sendVarInt(value)
- repeat
- local encoded = value & 0x7F
- value = value >> 7
- if value > 0 then
- encoded = encoded | 128
- end
- local _, err = self:sendByte(encoded)
- if err then
- return nil, err
- end
- until value <= 0
- return true, nil
-end
-
-function clientMt:sendShort(value)
- local _, err = self:sendByte((value >> 8) & 0xFF)
- if err then return nil, err end
- local _, err = self:sendByte(value & 0xFF)
- if err then return nil, err end
-end
-
-function clientMt:sendString(text)
- local _, err = self:sendShort(#text)
- if err then return nil, err end
- local _, err = self:sendData(text)
- if err then return nil, err end
-end
-
-function clientMt:handleError(err, result)
- if err then
- print("Got error")
- if self.connection then
- self.connection:close()
- end
- self.connection = nil
- end
- return result, err
-end
-
-function clientMt:sendPacket()
- local result, err = self:flush()
- return self:handleError(err, result)
-end
-
-function clientMt:connect()
- if self.connection then
- return true, nil
- end
-
- local conn, err = socket.connect(self.uri, 1883)
- if not conn then
- return nil, "failed to connect: " .. err
- end
- conn:setoption("tcp-nodelay", true)
- conn:setoption("linger", {on = true, timeout = 100})
- conn:settimeout(nil)
- self.connection = conn
-
- local _, err = self:sendByte(0x10)
- if err then return nil, self:handleError(err) end
-
- local length = 0
-
- local protocolName = "MQTT"
- local protocolNameLength = bytesUsedByString(protocolName)
- length = length + protocolNameLength
-
- local protocolVersion = 4
- local connectFlag = 0x02 -- 1 byte
- length = length + 2
-
- local keepAlive = 0 -- 2 bytes
- length = length + 2
-
- local clientIdLength = bytesUsedByString(self.id)
- length = length + clientIdLength
-
- _, err = self:sendVarInt(length)
- if err then return nil, self:handleError(err) end
-
- _, err = self:sendString(protocolName)
- if err then return nil, self:handleError(err) end
-
- _, err = self:sendByte(protocolVersion)
- if err then return nil, self:handleError(err) end
-
- _, err = self:sendByte(connectFlag)
- if err then return nil, self:handleError(err) end
-
- _, err = self:sendShort(keepAlive)
- if err then return nil, self:handleError(err) end
-
- _, err = self:sendString(self.id)
- if err then return nil, self:handleError(err) end
-
- return self:sendPacket()
-end
-
-function clientMt:disconnect(args)
- if not self.connection then
- return true, nil
- end
-
- local _, err = self:sendByte(0xE0)
- if err then return nil, self:handleError(err) end
-
- local _, err = self:sendByte(0x00)
- if err then return nil, self:handleError(err) end
-
- local result, err = self:sendPacket()
- self.connection:shutdown("both")
- local peer
- repeat
- peer = self.connection:getpeername()
- if peer then socket.sleep(0.02) end
- until peer
- self.connection:close()
- self.connection = nil
- return result, err
-end
-
-function clientMt:publish(args)
- local topic = args.topic
- local payload = args.payload
-
- local _, err = self:connect()
- if err then return nil, self:handleError(err) end
-
- local retain = args.retain and 0x01 or 0x00
- local _, err = self:sendByte(0x30 | retain)
- if err then return nil, self:handleError(err) end
-
- local topicLength = bytesUsedByString(topic)
- local payloadLength = #payload
-
- _, err = self:sendVarInt(topicLength + payloadLength)
- if err then return nil, self:handleError(err) end
-
- _, err = self:sendString(topic)
- if err then return nil, self:handleError(err) end
-
- _, err = self:sendData(payload)
- if err then return nil, self:handleError(err) end
-
- return self:sendPacket()
-end
-
-function clientMt:subscribe(args)
- local topic = args.topic
-
- local _, err = self:connect()
- if err then return nil, self:handleError(err) end
-
- local _, err = self:sendByte(0x82)
- if err then return nil, self:handleError(err) end
-
- local packetIdentifier = self.packetIdentifier
- self.packetIdentifier = self.packetIdentifier + 1
- if self.packetIdentifier > 0xFF00 then
- self.packetIdentifier = 1
- end
-
- local topicFilter = 0
- local topicLength = bytesUsedByString(topic)
-
- local length = 2 + topicLength + 1
- _, err = self:sendVarInt(length)
- if err then return nil, self:handleError(err) end
-
- _, err = self:sendShort(packetIdentifier)
- if err then return nil, self:handleError(err) end
-
- _, err = self:sendString(topic)
- if err then return nil, self:handleError(err) end
-
- _, err = self:sendByte(topicFilter)
- if err then return nil, self:handleError(err) end
-
- return self:sendPacket()
-end
-
-function clientMt:fireEvent(event, ...)
- if not self.eventHandlers then
- return
- end
- if not self.eventHandlers[event] then
- return
- end
- self.eventHandlers[event](...)
-end
-
-function clientMt:receiveBytes(count)
- local result, err, partial = nil, nil, ""
- while true do
- result, err, partial = client:receive(1 - #partial, partial)
- if err == "timeout" then
- coroutine.yield()
- elseif result then
- return result
- else
- return nil, err
- end
- end
-end
-
-function clientMt:receiveByte()
- return string.byte(self:receiveBytes(1))
-end
-
-function clientMt:receiveVarInt()
- local multiplier = 1
- local value = 0
- local encodedByte
- repeat
- encodedByte, err = receiveByte()
- if err then return nil, self:handleError(err) end
- value = value + (encodedByte & 127) * multiplier
- multiplier = multiplier * 128
- if multiplier > 128*128*128 then
- return nil, "malformed remaining length"
- end
- until (encodedByte & 128) ~= 0
- return value
-end
-
-function clientMt:receivePacket()
- local firstByte, err = self:receiveByte()
- if err then return self:handleError(err) end
-
- local remainingLength, err = self:receiveVarInt()
- if err then return self:handleError(err) end
-
- local packetType = (firstByte >> 4) & 0xF
- print("Got packet of type " .. packetType)
- if packetType == 2 then
- -- CONNACK
- assert(remainingLength == 2, "Invalid CONNACK length")
- local flags, err = self:receiveByte()
- if err then return self:handleError(err) end
- local returnCode, err = self:receiveByte()
- if err then return self:handleError(err) end
-
- print("Connected")
- local sessionPresent = flags & 1
- if not sessionPresent then
- self:fireEvent("connect")
- end
- else
- -- Unsupported or error
- self:handleError("Invalid packet type " .. packetType)
- end
-end
-
-function clientMt:threadReceive()
- -- local status, err = pcall(function()
- while true do
- if self.connection then
- local err = self:receiveAndHandlePacket()
- if err then
- self:handleError(err)
- end
- else
- coroutine.yield()
- end
- end
- -- end)
- -- if err then
- -- print(err)
- -- error(err)
- -- end
-end
-
-function clientMt:runForever()
- while true do
- coroutine.resume(self.thread)
- end
-end
-
-function clientMt:on(eventHandlers)
- self.eventHandlers = eventHandlers
-end
-
-function mqtt.client(args)
- local client = {
- uri = args.uri,
- id = args.id,
- reconnect = 5,
- connection = nil,
- packetIdentifier = 1,
- buffer = ""
- }
- setmetatable(client, {__index = clientMt})
- client.thread = coroutine.create(function() client:threadReceive() end)
- return client
-end
-
-return mqtt
diff --git a/get-image.lua b/get-image.lua
index 2a7244e..39fa4a4 100644
--- a/get-image.lua
+++ b/get-image.lua
@@ -1,5 +1,5 @@
package.path = package.path .. ';./controller-host/?/init.lua;./controller-host/?.lua'
-local mqtt = require("mymqtt")
+local mqtt = require("mqtt")
local client
function printTable(table, indentation)
@@ -38,6 +38,7 @@ client = mqtt.client {
id = "tool-get-image",
reconnect = 5,
version = mqtt.v311,
+ clean = "first"
}
client:on {
@@ -52,6 +53,4 @@ client:subscribe {
topic = 'spider/controller/#'
}
-client:disconnect()
-
---mqtt.run_ioloop(client)
+mqtt.run_ioloop(client)
diff --git a/get-ip.lua b/get-ip.lua
index dbedfe4..cc83dad 100644
--- a/get-ip.lua
+++ b/get-ip.lua
@@ -1,5 +1,6 @@
package.path = package.path .. ';./controller-host/?/init.lua;./controller-host/?.lua'
-local mqtt = require("mymqtt")
+--local mqtt = require("mymqtt")
+local mqtt = require("mqtt")
local client
local host = ...
@@ -39,6 +40,6 @@ client:on {
message = onMessage,
}
-client:runForever()
+--client:runForever()
---mqtt.run_ioloop(client)
+mqtt.run_ioloop(client)
diff --git a/test-image.lua b/test-image.lua
index 374cd5a..a165285 100644
--- a/test-image.lua
+++ b/test-image.lua
@@ -1,11 +1,6 @@
package.path = package.path .. ';./controller-host/?/init.lua;./controller-host/?.lua'
-local mqtt = require("mymqtt")
---local socket = require("socket")
---local client
-
-local fh = io.open("spider-image.bin", "rb")
-local contents = fh:read("a")
-fh:close()
+local mqtt = require("mqtt")
+local socket = require("socket")
local function onConnect(connack)
if connack.rc ~= 0 then
@@ -13,6 +8,12 @@ local function onConnect(connack)
os.exit(1)
end
+ assert(client:publish {
+ topic = "spider/telemetry/camfeed",
+ payload = string.rep("a", 53772600),
+ qos = 0
+ })
+
print("Connected and subscribed")
end
@@ -32,18 +33,5 @@ client:on {
end,
}
---mqtt.run_ioloop(client)
-
-
---while true do
-assert(client:publish {
- topic = "spider/telemetry/camfeed",
- payload = "a" .. contents,
- qos = 0
- })
---end
-
-print("Calling disconnect")
-require("socket").sleep(0.002)
-client:disconnect()
+mqtt.run_ioloop(client)