From 3eee21480bbe356a1904bb6054adb97a3e474177 Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Sat, 20 Jul 2024 17:51:48 +0200 Subject: [PATCH] Improved version of luamqtt with fewer bugs --- controller-client/socket-test.lua | 19 + controller-host/mqtt/bitwrap.lua | 1 + controller-host/mqtt/client.lua | 1109 ++++++++++------- .../mqtt/connector/base/buffered_base.lua | 89 ++ .../mqtt/connector/base/luasec.lua | 29 + .../mqtt/connector/base/non_buffered_base.lua | 67 + controller-host/mqtt/connector/copas.lua | 121 ++ controller-host/mqtt/connector/init.lua | 34 + controller-host/mqtt/connector/luasocket.lua | 142 +++ controller-host/mqtt/connector/nginx.lua | 102 ++ controller-host/mqtt/const.lua | 19 + controller-host/mqtt/init.lua | 248 +++- controller-host/mqtt/ioloop.lua | 256 ++-- controller-host/mqtt/log.lua | 17 + controller-host/mqtt/loop/copas.lua | 72 ++ controller-host/mqtt/loop/detect.lua | 30 + controller-host/mqtt/loop/init.lua | 37 + controller-host/mqtt/loop/ioloop.lua | 24 + controller-host/mqtt/loop/nginx.lua | 76 ++ controller-host/mqtt/luasocket-copas.lua | 48 - controller-host/mqtt/luasocket.lua | 54 - controller-host/mqtt/luasocket_ssl.lua | 56 - controller-host/mqtt/ngxsocket.lua | 55 - controller-host/mqtt/protocol.lua | 385 ++++-- controller-host/mqtt/protocol4.lua | 555 +++++++-- controller-host/mqtt/protocol5.lua | 1031 +++++++++++---- controller-host/mqtt/tools.lua | 72 ++ controller-host/mymqtt.lua | 359 ------ get-image.lua | 7 +- get-ip.lua | 7 +- test-image.lua | 30 +- 31 files changed, 3487 insertions(+), 1664 deletions(-) create mode 100644 controller-client/socket-test.lua create mode 100644 controller-host/mqtt/connector/base/buffered_base.lua create mode 100644 controller-host/mqtt/connector/base/luasec.lua create mode 100644 controller-host/mqtt/connector/base/non_buffered_base.lua create mode 100644 controller-host/mqtt/connector/copas.lua create mode 100644 controller-host/mqtt/connector/init.lua create mode 100644 controller-host/mqtt/connector/luasocket.lua create mode 100644 controller-host/mqtt/connector/nginx.lua create mode 100644 controller-host/mqtt/const.lua create mode 100644 controller-host/mqtt/log.lua create mode 100644 controller-host/mqtt/loop/copas.lua create mode 100644 controller-host/mqtt/loop/detect.lua create mode 100644 controller-host/mqtt/loop/init.lua create mode 100644 controller-host/mqtt/loop/ioloop.lua create mode 100644 controller-host/mqtt/loop/nginx.lua delete mode 100644 controller-host/mqtt/luasocket-copas.lua delete mode 100644 controller-host/mqtt/luasocket.lua delete mode 100644 controller-host/mqtt/luasocket_ssl.lua delete mode 100644 controller-host/mqtt/ngxsocket.lua delete mode 100644 controller-host/mymqtt.lua 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)