From cbac96837c417ead9cf41dd040399f3af6f6da99 Mon Sep 17 00:00:00 2001 From: alon-tchelet Date: Tue, 25 Jun 2024 16:53:50 +0200 Subject: [PATCH 1/8] remove media presents for MA AVRs Harman decision applied on device and now propagated to SmartThings Signed-off-by: alon-tchelet --- drivers/SmartThings/harman-luxury/profiles/maX10.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/drivers/SmartThings/harman-luxury/profiles/maX10.yaml b/drivers/SmartThings/harman-luxury/profiles/maX10.yaml index d5161e8b49..b07963bd2c 100644 --- a/drivers/SmartThings/harman-luxury/profiles/maX10.yaml +++ b/drivers/SmartThings/harman-luxury/profiles/maX10.yaml @@ -10,8 +10,6 @@ components: version: 1 - id: audioTrackData version: 1 - - id: mediaPresets - version: 1 - id: mediaPlayback version: 1 - id: mediaTrackControl From 72cc6a5c97d62167d92120ff433f23631fb9ea77 Mon Sep 17 00:00:00 2001 From: alon-tchelet Date: Tue, 25 Jun 2024 17:24:02 +0200 Subject: [PATCH 2/8] remove keypad input support Harman don't want it and don't plan to use it in the future Signed-off-by: alon-tchelet --- .../harman-luxury/profiles/harman-luxury.yaml | 2 -- drivers/SmartThings/harman-luxury/src/api/apis.lua | 13 ------------- drivers/SmartThings/harman-luxury/src/init.lua | 9 --------- 3 files changed, 24 deletions(-) diff --git a/drivers/SmartThings/harman-luxury/profiles/harman-luxury.yaml b/drivers/SmartThings/harman-luxury/profiles/harman-luxury.yaml index 8b3da53bde..7836421cf6 100644 --- a/drivers/SmartThings/harman-luxury/profiles/harman-luxury.yaml +++ b/drivers/SmartThings/harman-luxury/profiles/harman-luxury.yaml @@ -20,8 +20,6 @@ components: version: 1 - id: audioNotification version: 1 - - id: keypadInput - version: 1 - id: refresh version: 1 categories: diff --git a/drivers/SmartThings/harman-luxury/src/api/apis.lua b/drivers/SmartThings/harman-luxury/src/api/apis.lua index 01b646d475..112626d076 100644 --- a/drivers/SmartThings/harman-luxury/src/api/apis.lua +++ b/drivers/SmartThings/harman-luxury/src/api/apis.lua @@ -293,19 +293,6 @@ function APIs.GetPlayerState(ip) return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "getPlayerState") end ---- key input APIs ------------------------------------ - ---- invoke smartthings:sendKey on ip ----@param ip string ----@param key string ----@return boolean|number|string|table|nil, nil|string -function APIs.InvokeSendKey(ip, key) - local value = { - NsdkSmartThingsKey = key, - } - return invoke.ActivateValue(ip, SMARTTHINGS_PATH .. "sendKey", value) -end - --- check for values change APIs ------------------------------------ --- invoke smartthings:updateValues on ip diff --git a/drivers/SmartThings/harman-luxury/src/init.lua b/drivers/SmartThings/harman-luxury/src/init.lua index d2519ef342..47f09161cf 100644 --- a/drivers/SmartThings/harman-luxury/src/init.lua +++ b/drivers/SmartThings/harman-luxury/src/init.lua @@ -243,12 +243,6 @@ local function device_init(driver, device) local supportedInputSources, _ = api.GetSupportedInputSources(device_ip) device:emit_event(capabilities.mediaInputSource.supportedInputSources(supportedInputSources)) - -- set supported keypad inputs - device:emit_event(capabilities.keypadInput.supportedKeyCodes( - {"UP", "DOWN", "LEFT", "RIGHT", "SELECT", "BACK", "EXIT", "MENU", "SETTINGS", "HOME", "NUMBER0", - "NUMBER1", "NUMBER2", "NUMBER3", "NUMBER4", "NUMBER5", "NUMBER6", "NUMBER7", "NUMBER8", - "NUMBER9"})) - log.trace(string.format("device IP: %s", device_ip)) create_check_for_updates_thread(device) @@ -387,9 +381,6 @@ local driver = Driver("Harman Luxury", { [capabilities.mediaTrackControl.commands.nextTrack.NAME] = handlers.handle_next_track, [capabilities.mediaTrackControl.commands.previousTrack.NAME] = handlers.handle_previous_track, }, - [capabilities.keypadInput.ID] = { - [capabilities.keypadInput.commands.sendKey.NAME] = handlers.handle_send_key, - }, }, supported_capabilities = {capabilities.switch, capabilities.audioMute, capabilities.audioVolume, capabilities.mediaPresets, capabilities.audioNotification, capabilities.mediaPlayback, From ef2c3826af7fbc0233036fe7068de77aee8d6585 Mon Sep 17 00:00:00 2001 From: alon-tchelet Date: Tue, 25 Jun 2024 17:28:16 +0200 Subject: [PATCH 3/8] add lustre/ws.lua so we can fix small bug in library We are moving the device communications between the hub and the device to WebSockets. Until the ST lustre library WebSocket keep_alive ping bug is resolved, this lustre/ws.lua will be used instead. Signed-off-by: alon-tchelet --- .../harman-luxury/src/lustre/ws.lua | 655 ++++++++++++++++++ 1 file changed, 655 insertions(+) create mode 100644 drivers/SmartThings/harman-luxury/src/lustre/ws.lua diff --git a/drivers/SmartThings/harman-luxury/src/lustre/ws.lua b/drivers/SmartThings/harman-luxury/src/lustre/ws.lua new file mode 100644 index 0000000000..e0b0cfa608 --- /dev/null +++ b/drivers/SmartThings/harman-luxury/src/lustre/ws.lua @@ -0,0 +1,655 @@ +local cosock = require"cosock" +local socket = require"cosock.socket" +local Request = require"luncheon.request" +local Response = require"luncheon.response" +local send_utils = require"luncheon.utils" +local Handshake = require"lustre.handshake" +local Key = require"lustre.handshake.key" +local Config = require"lustre.config" +local Frame = require"lustre.frame" +local FrameHeader = + require"lustre.frame.frame_header" +local OpCode = require"lustre.frame.opcode" +local CloseCode = + require"lustre.frame.close".CloseCode +local Message = require"lustre.message" +local log = require"log" +-- disable debugging logs +log.debug = function(...) end +log.trace = function(...) end + +local utils = require"lustre.utils" + +---@class WebSocket +--- +---@field public id number|string +---@field public url string the endpoint to hit +---@field public socket table lua socket +---@field public handshake_key string key used in the websocket handshake +---@field public config Config +---@field private handshake Handshake +---@field private _send_tx table +---@field private _send_rx table +---@field private _recv_tx table +---@field private _recv_rx table +---@field private is_client boolean +local WebSocket = {} +WebSocket.__index = WebSocket + +---Create new client object +---@param socket table connected tcp socket +---@param url string url to connect +---@param config Config +---@return WebSocket +---@return string|nil +function WebSocket.client(socket, url, config) + local _send_tx, _send_rx = cosock.channel.new() + local _recv_tx, _recv_rx = cosock.channel.new() + local config = config or Config.default() + local ret = setmetatable( + { + is_client = true, + socket = socket, + url = url or "/", + handshake = Handshake.client(nil, config._protocols, config._extensions, config._extra_headers), + config = config, + _send_tx = _send_tx, + _send_rx = _send_rx, + _recv_tx = _recv_tx, + _recv_rx = _recv_rx, + id = math.random(), + state = "Active", + }, WebSocket) + return ret +end + +---Create a server side websocket (NOT YET IMPLEMENTED) +---@param socket table the cosock.tcp socket to use +---@param config Config The websocket configuration +---@return WebSocket +---@return string|nil @If an error occurs, returns the error message +function WebSocket.server(socket, config) + return nil, "Not yet implemented" +end + +---Receive the next message from this websocket +---@return Message +---@return string|nil +function WebSocket:receive() + log.trace("WebSocket:receive") + self._waker = nil + local result = self._recv_rx:receive() + if result.err then + return nil, result.err + end + return result.msg +end + +---@param text string +---@return number, string|nil +function WebSocket:send_text(text) + if self.state ~= "Active" then + return nil, "closed" + end + local valid_utf8, utf8_err = + utils.validate_utf8(text) + if not valid_utf8 then + return nil, utf8_err + end + return self:send(Message.new("text", text)) +end + +---@param bytes string +---@return number +---@return number, string|nil +function WebSocket:send_bytes(bytes) + return self:send(Message.new("binary", bytes)) +end + +-- TODO remove the fragmentation code duplication in the `send_text` and `send_bytes` apis +-- TODO Could perhaps remove those apis entirely. +---@param message Message +---@return number, string|nil +function WebSocket:send(message) + log.trace("WebSocket:send", message.type) + local data_idx = 1 + local frames_sent = 0 + if self.state ~= "Active" then + return nil, "closed" + end + local opcode + if message.type == "text" then + opcode = OpCode.text() + else + opcode = OpCode.binary() + end + repeat + log.trace("send fragment top") + local header = FrameHeader.default() + local payload = "" + if (message.data:len() - data_idx + 1) + > self.config._max_frame_size then + header:set_fin(false) + end + payload = string.sub(message.data, data_idx, + data_idx + self.config._max_frame_size) + if data_idx ~= 1 then + header:set_opcode(OpCode.continue()) + else + header:set_opcode(opcode) + end + header:set_length(#payload) + local frame = + Frame.from_parts(header, payload) + frame:set_mask() -- todo handle client vs server + local tx, rx = cosock.channel.new() + local suc, err = self._send_tx:send( + {frame = frame, reply = tx}) + if err then + log.error("channel send error:", err) + end + local result = rx:receive() + if result.err then + return nil, result.err + end + data_idx = data_idx + frame:payload_len() + frames_sent = frames_sent + 1 + until message.data:len() <= data_idx + return 1 +end + +---@return number, string|nil +function WebSocket:client_handshake_and_start(host, port) + if not self.is_client then -- todo use metatables to enforce this + log.error(self.id, "Invalid client websocket") + return nil, "only a client can connect" + end + -- Do handshake + log.debug(self.id, "sending handshake") + local success, err = self.handshake:send( + self.socket, self.url, + string.format("%s:%d", host, port)) + log.debug(self.id, "handshake complete", + success or err) + if not success then + return nil, "invalid handshake: " .. err + end + cosock.spawn(function() + self:_receive_loop() + end, "Client receive loop") + return 1 +end + +---@return number, string|nil +function WebSocket:connect(host, port) + log.trace(self.id, "WebSocket:connect", host, + port) + if not self.is_client then -- todo use metatables to enforce this + log.error(self.id, "Invalid client websocket") + return nil, "only a client can connect" + end + if not host or not port then + return nil, "missing host or port" + end + log.debug(self.id, "calling socket.connect") + local r, err = self.socket:connect(host, port) + log.debug(self.id, "Socket connect completed", + r or err) + if not r then + return nil, "socket connect failure: " .. err + end + + return self:client_handshake_and_start(host, port) +end + +function WebSocket:accept() + return nil, "Not yet implemented" +end + +---@param close_code CloseCode +---@param reason string +---@return number 1 if success +---@return string|nil +function WebSocket:close(close_code, reason) + log.debug("sending close message", + close_code.type or close_code.value, reason) + if self.state == "Active" then + local close_frame = Frame.close(close_code, + reason):set_mask() -- TODO client vs server + local tx, reply = cosock.channel.new() + reply:settimeout(0.5) + log.debug("sending frame to socket task") + local suc, err = self._send_tx:send( + {frame = close_frame, reply = tx}) + log.debug("sent frame to socket task", suc, + err) + if not suc then + return nil, "channel error:" .. err + end + log.debug("waiting on reply") + local reply = reply:receive() + log.debug("reply received") + return reply + elseif self.state == "ClosedBySelf" then + self.state = "CloseAcknowledged" + end + + return 1, log.debug("closed websocket") +end + +---Cosock internal interface for using `cosock.socket.select` +---@param kind string +---@param waker fun() +function WebSocket:setwaker(kind, waker) + assert(kind == "recvr", + "unsupported wake kind: " .. tostring(kind)) + assert(self._waker == nil or waker == nil, + "waker already set, receive can't be waited on from multiple places at once") + self._waker = waker + + -- if messages waiting, immediately wake + if #self._recv_tx.link.queue > 0 and waker then + waker() + end +end + +---Spawn the receive loop +---@return string|nil +function WebSocket:_receive_loop() + log.trace(self.id, "starting receive loop") + local loop_state = { + partial_frames = nil, + received_bytes = 0, + frames_since_last_ping = 0, + pending_pongs = 0, + multiframe_message = false, + utf8_check_backward_idx = 0, + msg_type = nil, + } + local order = false + while self.state ~= "CloseAcknowledged" + and self.state ~= "Terminated" do + log.trace(self.id, "loop top") + local rs = (order + and {self._send_rx, self.socket}) + or {self.socket, self._send_rx} + order = not order + local recv, _, err = socket.select(rs, nil, + self.config._keep_alive) + log.debug((recv and "recv") or "~recv", + err or "") + if not recv then + if self:_handle_select_err(loop_state, err) then + return + end + elseif self:_handle_recvs(loop_state, recv, 1) then + break + end + end + log.debug("Closing socket") + self.socket:close() + log.debug("Closing channel") + self._send_rx:close() + log.debug("Channel closed") +end + +function WebSocket:_handle_recvs(state, recv, idx) + log.trace(self.id, "_handle_recvs") + if recv[idx] == self.socket then + return self:_handle_recv_ready(state) and 1 + end + if recv[idx] == self._send_rx then -- frames we need to send on the socket + return self:_handle_send_ready() + end +end + +function WebSocket:_handle_select_err(state, err) + log.debug(self.id, "selected err:", err) + if err == "timeout" then + if state.pending_pongs >= 2 then -- TODO max number of pings without a pong could be configurable + self._recv_tx:send({ + err = "no response to ping", + }) + self.state = "Terminated" + log.debug("Closing socket") + self.socket:close() + return 1 + end + local fm = Frame.ping():set_mask() + local sent_bytes, err = + send_utils.send_all(self.socket, fm:encode()) + if not err then + log.debug(self.id, "SENT FRAME: \n%s\n\n") + state.pending_pongs = + state.pending_pongs + 1 + else + self._recv_rx:send({err = err}) + self.state = "Terminated" + log.debug("Closing socket") + self.socket:close() + return 1 + end + end +end + +function WebSocket:_handle_recv_ready(state) + log.debug(self.id, "selected socket") + local frame, err = + Frame.from_stream(self.socket) + log.debug(self.id, "build frame", frame or err) + if not frame then + log.debug("error building frame", err) + if err == "invalid opcode" or err + == "invalid rsv bit" then + log.warn(self.id, + "PROTOCOL ERR: received frame with " .. err) + self._send_tx:send({ + frame = Frame.close(CloseCode.protocol()), + }) + elseif err == "timeout" then + -- TODO retry receiving the frame, give partially received frame + self._recv_tx:send({err = err}) + if self._waker then + self._waker() + end + elseif err == "closed" then + log.debug("socket was closed", self.state) + if self.state == "Active" or self.state + == "ClosedBySelf" then + self._recv_tx:send({err = err}) + if self._waker then + self._waker() + end + self.state = "Terminated" + end + return 1 + else + self._recv_tx:send({err = err}) + if self._waker then + self._waker() + end + end + return + end + log.debug(self.id, + string.format("RECEIVED FRAME %s %s", + frame.header.opcode.type, + frame.header.opcode.sub)) + if frame:is_control() then + return self:_handle_recv_control_frame(frame, + state) + end + + -- Should we close because we have been waiting to long for a ping? + -- We might not need to do this, because it wasn't prioritized + -- with a test case in autobahn + if state.pending_pongs > 0 then + state.frames_since_last_ping = + state.frames_since_last_ping + 1 + if state.frames_since_last_ping + > self.config._max_frames_without_pong then + state.frames_since_last_ping = 0 + log.trace(self.id, + "PROTOCOL ERR: received too many frames while waiting for pong") + self._send_tx:send({ + frame = Frame.close(CloseCode.policy(), + "no pong after ping"), + }) + return + end + end + + -- handle fragmentation + if state.multiframe_message then + if frame.header.opcode.sub ~= "continue" then + log.warn("Expected continue frame found ", + frame.header.opcode.sub) + self._send_tx:send({ + frame = Frame.close(CloseCode.protocol(), + "unexpected continue frame"), + }) + return + end + if state.msg_type == "text" then + if self:_handle_recv_text_frame(frame, state) then + return + end + end + elseif frame.header.opcode.sub == "continue" then + log.warn("Unexpected continue frame") + self._send_tx:send({ + frame = Frame.close(CloseCode.protocol(), + "unexpected continue frame"), + }) + return + else + if frame.header.opcode.sub == "text" then + if self:_handle_recv_text_frame(frame, state) then + return + end + end + state.msg_type = frame.header.opcode.sub + state.multiframe_message = + not frame:is_final() + end + -- aggregate payloads + if not frame:is_final() then + state.received_bytes = + state.received_bytes + frame:payload_len() + -- TODO what should happen if we get message that is too big for the library? + -- We are currently truncating the message. + if state.received_bytes + <= self.config.max_message_size then + state.partial_frames = + (state.partial_frames or "") + .. frame.payload + else + log.warn(self.id, + "truncating message thats bigger than max config size") + end + return + else + state.multiframe_message = false + end + + -- coalesce frame payloads into single message payload + local full_payload = frame.payload + if state.partial_frames then + full_payload = state.partial_frames + .. frame.payload + state.partial_frames = nil + end + if state.msg_type == "text" then + log.debug("checking for valid utf8") + local valid_utf8, utf8_err = + utils.validate_utf8(full_payload) + log.trace("valid?", not not valid_utf8, + utf8_err) + if not valid_utf8 then + log.warn( + "Received invalid utf8 text message, closing", + utf8_err) + send_utils.send_all(self.socket, + Frame.close(CloseCode.protocol(), utf8_err):encode()) + self.socket:close() + self.state = "Terminated" + self._recv_tx:send({err = "closed"}) + if self._waker then + self._waker() + end + return + end + end + self._recv_tx:send({ + msg = Message.new(state.msg_type, full_payload), + }) + if self._waker then + self._waker() + end +end + +--- +---@param frame Frame +---@param state table +function WebSocket:_handle_recv_text_frame(frame, + state) + log.debug("checking for valid utf8") + local valid_utf8, utf8_err, err_idx = + utils.validate_utf8((state.partial_utf8_bytes + or "") .. frame.payload) + log.trace("valid?", not not valid_utf8, + utf8_err, err_idx) + if not valid_utf8 then + if utf8_err == "Invalid UTF-8 too short" then + state.partial_utf8_bytes = + ((state.partial_frames or "") + .. frame.payload):sub(err_idx) + log.debug( + "utf8 too short updated partial_utf8_bytes", + state.partial_utf8_bytes) + if not frame:is_final() then + return + end + else + state.partial_utf8_bytes = "" + end + + log.warn( + "Received invalid utf8 text frame, closing", + utf8_err) + self._send_tx:send({ + frame = Frame.close(CloseCode.protocol(), + utf8_err), + }) + self._recv_tx:send({err = "closed"}) + self.state = "Terminated" + self.socket:close() + return + else + state.partial_utf8_bytes = "" + end +end + +function WebSocket:_handle_recv_control_frame( + frame, state) + if not frame:is_final() then + log.trace(self.id, + "PROTOCOL ERR: received non final control frame") + self._send_tx:send({ + frame = Frame.close(CloseCode.protocol()), + }) + return + end + local control_type = frame.header.opcode.sub + if frame:payload_len() + > Frame.MAX_CONTROL_FRAME_LENGTH then + log.trace(self.id, + "PROTOCOL ERR: received control frame that is too big") + self._send_tx:send({ + frame = Frame.close(CloseCode.protocol()), + }) + return + end + if control_type == "ping" then + local fm = + Frame.pong(frame.payload):set_mask() + local sent_bytes, err = + send_utils.send_all(self.socket, fm:encode()) + if not sent_bytes then + self._recv_tx:send({ + err = "failed to send pong in response to ping: " + .. err, + }) + end + return + elseif control_type == "pong" then + state.pending_pongs = + math.max(state.pending_pongs - 1, 0) -- TODO this functionality is not tested by the test framework + state.frames_since_last_ping = 0 + elseif control_type == "close" then + self._send_tx:send({ + frame = Frame.close( + CloseCode.decode(frame.payload)), + }) + end +end + +function WebSocket:_handle_send_ready() + log.debug(self.id, "selected channel") + local event, err = self._send_rx:receive() + log.debug("received from rx") + if not event then + log.error( + "error receiving event from _send_rx", err) + return + end + ---@type Frame, cosock.channel + local frame, reply = event.frame, event.reply + log.debug("encoding frame: ", + frame.header.opcode.type, + frame.header.opcode.sub) + local bytes = frame:encode() + log.debug("sending all bytes") + local sent_bytes, err = + send_utils.send_all(self.socket, bytes) + log.debug("sent bytes") + if not sent_bytes then + local closed = err:match("close") + if closed and self.state == "Active" then + log.debug("closed error", err) + if reply and reply.send then + reply:send({err = err}) + else + log.error(string.format("No reply channel in event for progating error: %s", err)) + end + end + if not closed then + if reply and reply.send then + reply:send({err = err}) + else + log.error(string.format("No reply channel in event for progating error: %s", err)) + end + end + return + end + log.debug(self.id, "SENT FRAME") + local ret + + if frame:is_close() then + return self:_handle_sent_close_frame() + end + if reply then + reply:send({ok = 1}) + end +end + +function WebSocket:_handle_sent_close_frame() + if self.state == "Active" then + self.state = "ClosedBySelf" + end + if self.state == "ClosedByPeer" then + self.state = "CloseAcknowledged" + self.socket:close() + if self._waker then + self._waker() + end + return 1 + end +end + +function WebSocket:_handle_recvd_close_frame() + if self.state == "Active" then + self.state = "ClosedByPeer" + end + if self.state == "ClosedBySelf" then + self.state = "CloseAcknowledged" + self.socket:close() + self._recv_tx:send({err = "closed"}) + if self._waker then + self._waker() + end + return 1 + end +end + +return WebSocket \ No newline at end of file From 76666037c39a84eb4ff4d73531854de9fba0c382 Mon Sep 17 00:00:00 2001 From: alon-tchelet Date: Tue, 25 Jun 2024 17:29:04 +0200 Subject: [PATCH 4/8] add a specific timeout error handler in REST HTTP reply handler Signed-off-by: alon-tchelet --- drivers/SmartThings/harman-luxury/src/api/nsdk.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/drivers/SmartThings/harman-luxury/src/api/nsdk.lua b/drivers/SmartThings/harman-luxury/src/api/nsdk.lua index c712408bac..c18c38782d 100644 --- a/drivers/SmartThings/harman-luxury/src/api/nsdk.lua +++ b/drivers/SmartThings/harman-luxury/src/api/nsdk.lua @@ -88,7 +88,12 @@ local function handleReply(func_name, u, sink, code, valLocationFunc) end return nil, err else -- UNKNOWN VALUE - local err = string.format("Error in %s: Unknown return value: code: %s, sink: %s", func_name, code, sink) + local err + if string.find(code, "timeout") then + err = string.format("Error in %s: Connection timeout", func_name) + else + err = string.format("Error in %s: Unknown return value: code: %s, sink: %s", func_name, code, sink) + end log.error(err) return nil, err end From 0a35f4b05b996664ce4adc8e3d444144da9f8c74 Mon Sep 17 00:00:00 2001 From: alon-tchelet Date: Tue, 25 Jun 2024 17:39:15 +0200 Subject: [PATCH 5/8] delete unused APIs the removed APIs are no longer used after moving to WebSockets Signed-off-by: alon-tchelet --- .../harman-luxury/src/api/apis.lua | 224 ------------------ 1 file changed, 224 deletions(-) diff --git a/drivers/SmartThings/harman-luxury/src/api/apis.lua b/drivers/SmartThings/harman-luxury/src/api/apis.lua index 112626d076..49b9c5cded 100644 --- a/drivers/SmartThings/harman-luxury/src/api/apis.lua +++ b/drivers/SmartThings/harman-luxury/src/api/apis.lua @@ -8,19 +8,11 @@ local invoke = require "api.invokes" --- system paths ----------------------------------------- -local UUID_PATH = "settings:/system/memberId" -local MAC_PATH = "settings:/system/primaryMacAddress" -local MEMBER_ID_PATH = "settings:/system/memberId" local MANUFACTURER_NAME_PATH = "settings:/system/manufacturer" local DEVICE_NAME_PATH = "settings:/deviceName" local MODEL_NAME_PATH = "settings:/system/modelName" local PRODUCT_NAME_PATH = "settings:/system/productName" ---- SmartThings paths ---------------------------------- -local SMARTTHINGS_PATH = "smartthings:" -local SMARTTHINGS_AUDIO_PATH = "smartthings:audio/" -local SMARTTHINGS_MEDIA_PATH = "smartthings:media/" - ---------------------------------------------------------- --- APIs ---------------------------------------------------------- @@ -29,27 +21,6 @@ local APIs = {} --- system APIs ------------------------------------------ ---- get UUID from Harman Luxury on ip ----@param ip string ----@return string|nil, nil|string -function APIs.GetUUID(ip) - return get.String(ip, UUID_PATH) -end - ---- get MAC address from Harman Luxury on ip ----@param ip string ----@return string|nil, nil|string -function APIs.GetMAC(ip) - return get.String(ip, MAC_PATH) -end - ---- get Member ID from Harman Luxury on ip ----@param ip string ----@return string|nil, nil|string -function APIs.GetMemberId(ip) - return get.String(ip, MEMBER_ID_PATH) -end - --- get device manufacturer name from Harman Luxury on ip ---@param ip string ---@return string|nil, nil|string @@ -107,199 +78,4 @@ function APIs.GetSupportedInputSources(ip) return invoke.Activate(ip, SMARTTHINGS_PATH .. "getSupportedInputSources") end ---- power manager APIs ----------------------------------- - ---- invoke smartthings:setOn on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.SetOn(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "setOn") -end - ---- invoke smartthings:setOff on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.SetOff(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "setOff") -end - ---- get current power state Harman Luxury on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.GetPowerState(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "powerStatus") -end - ---- audio APIs ------------------------------------ - ---- set Mute value of Harman Luxury media player on ip ----@param ip string ----@param value boolean ----@return boolean|number|string|table|nil, nil|string -function APIs.SetMute(ip, value) - return set.Bool(ip, SMARTTHINGS_AUDIO_PATH .. "mute", value) -end - ---- get Mute value of Harman Luxury media player on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.GetMute(ip) - return get.Bool(ip, SMARTTHINGS_AUDIO_PATH .. "mute") -end - ---- set Volume value of Harman Luxury media player on ip ----@param ip string ----@param value integer ----@return boolean|number|string|table|nil, nil|string -function APIs.SetVol(ip, value) - return set.I32(ip, SMARTTHINGS_AUDIO_PATH .. "volume", value) -end - ---- get Volume value of Harman Luxury media player on ip ----@param ip string ----@return number|nil, nil|string -function APIs.GetVol(ip) - return get.I32(ip, SMARTTHINGS_AUDIO_PATH .. "volume") -end - ---- invoke smartthings:audio/getAudioTrackData on ip ----@class AudioTrackData ----@field trackdata table ----@field supportedPlaybackCommands table ----@field supportedTrackControlCommands table ----@field totalTime number ----@param ip string ----@return AudioTrackData|nil, nil|string -function APIs.getAudioTrackData(ip) - local val, err = invoke.Activate(ip, SMARTTHINGS_AUDIO_PATH .. "getAudioTrackData") - if val then - local audioTrackData = { - trackdata = { - title = val.title or "", - artist = val.artist or nil, - album = val.album or nil, - albumArtUrl = val.albumArtUrl or nil, - mediaSource = val.mediaSource or nil, - }, - supportedPlaybackCommands = val.supportedPlaybackCommands, - supportedTrackControlCommands = val.supportedTrackControlCommands, - totalTime = val.totalTime, - } - return audioTrackData, nil - else - return nil, err - end -end - ---- Audio Notification API ------------------------------------ - ---- invoke Audio Notification of Harman Luxury on ip ----@param ip string ----@param uri string ----@param level number ----@return boolean|number|string|table|nil, nil|string -function APIs.SendAudioNotification(ip, uri, level) - local value = { - smartthingsAudioNotification = { - uri = uri, - level = level, - }, - } - return invoke.ActivateValue(ip, SMARTTHINGS_PATH .. "playAudioNotification", value) -end - ---- media player APIs ------------------------------------ - ---- set Input Source value of Harman Luxury on ip ----@param ip string ----@param source string ----@return boolean|number|string|table|nil, nil|string -function APIs.SetInputSource(ip, source) - local value = { - string_ = source, - } - return invoke.ActivateValue(ip, SMARTTHINGS_MEDIA_PATH .. "setInputSource", value) -end - ---- get Input Source value of Harman Luxury on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.GetInputSource(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "getInputSource") -end - ---- play Media Preset with given id value on Harman Luxury on ip ----@param ip string ----@param id integer ----@return boolean|number|string|table|nil, nil|string -function APIs.PlayMediaPreset(ip, id) - local value = { - i32_ = id, - } - return invoke.ActivateValue(ip, SMARTTHINGS_MEDIA_PATH .. "playMediaPreset", value) -end - ---- get Media Preset list of Harman Luxury on ip ----@param ip string ----@return table|nil, nil|string -function APIs.GetMediaPresets(ip) - local val, err = invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "getMediaPresets") - if val then - return val.presets, nil - else - return nil, err - end -end - ---- invoke smartthings:media/setPlay on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.InvokePlay(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "setPlay") -end - ---- invoke smartthings:media/setPause on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.InvokePause(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "setPause") -end - ---- invoke smartthings:media/setNextTrack on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.InvokeNext(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "setNextTrack") -end - ---- invoke smartthings:media/setPrevTrack on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.InvokePrevious(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "setPrevTrack") -end - ---- invoke smartthings:media/setStop on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.InvokeStop(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "setStop") -end - ---- invoke smartthings:media/setStop on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.GetPlayerState(ip) - return invoke.Activate(ip, SMARTTHINGS_MEDIA_PATH .. "getPlayerState") -end - ---- check for values change APIs ------------------------------------ - ---- invoke smartthings:updateValues on ip ----@param ip string ----@return table|nil, nil|string -function APIs.InvokeGetUpdates(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "updateValues") -end - return APIs From d2467c15f039aacaacb8549f6c0ec1a3d22f0baa Mon Sep 17 00:00:00 2001 From: alon-tchelet Date: Tue, 25 Jun 2024 17:44:11 +0200 Subject: [PATCH 6/8] move credential generation to device side this is done to further match the discovery behaviour to the example code and to return the handshake process control to the device. Signed-off-by: alon-tchelet --- .../harman-luxury/src/api/apis.lua | 35 +++--- .../SmartThings/harman-luxury/src/disco.lua | 109 +++++++----------- .../SmartThings/harman-luxury/src/init.lua | 2 +- 3 files changed, 62 insertions(+), 84 deletions(-) diff --git a/drivers/SmartThings/harman-luxury/src/api/apis.lua b/drivers/SmartThings/harman-luxury/src/api/apis.lua index 49b9c5cded..b068289a74 100644 --- a/drivers/SmartThings/harman-luxury/src/api/apis.lua +++ b/drivers/SmartThings/harman-luxury/src/api/apis.lua @@ -12,6 +12,8 @@ local MANUFACTURER_NAME_PATH = "settings:/system/manufacturer" local DEVICE_NAME_PATH = "settings:/deviceName" local MODEL_NAME_PATH = "settings:/system/modelName" local PRODUCT_NAME_PATH = "settings:/system/productName" +local INIT_CREDENTIAL_PATH = "smartthings:initCredentialsToken" +local CREDENTIAL_PATH = "settings:/smartthings/userToken" ---------------------------------------------------------- --- APIs @@ -57,25 +59,28 @@ function APIs.SetDeviceName(ip, value) return set.String(ip, DEVICE_NAME_PATH, value) end ---- get active credential token from a Harman Luxury device on ip +--- initialise a new credential token from Harman Luxury on ip ---@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.InitCredentialsToken(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "initCredentialsToken") -end - ---- get active credential token from a Harman Luxury device on ip ----@param ip string ----@return boolean|number|string|table|nil, nil|string -function APIs.GetCredentialsToken(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "getCredentialsToken") +---@return string|nil, nil|string +function APIs.init_credential_token(ip) + local val, err = invoke.Activate(ip, INIT_CREDENTIAL_PATH) + if err then + return nil, err + else + if type(val) == "string" then + return val, nil + else + err = string.format("Device with IP:%s failed to generate a valid credential", ip) + return nil, err + end + end end ---- get supported input sources from a Harman Luxury device on ip +--- get device current active token from Harman Luxury on ip ---@param ip string ----@return table|nil, nil|string -function APIs.GetSupportedInputSources(ip) - return invoke.Activate(ip, SMARTTHINGS_PATH .. "getSupportedInputSources") +---@return string|nil, nil|string +function APIs.GetActiveCredentialToken(ip) + return get.String(ip, CREDENTIAL_PATH) end return APIs diff --git a/drivers/SmartThings/harman-luxury/src/disco.lua b/drivers/SmartThings/harman-luxury/src/disco.lua index 41fc6a8395..6044276ada 100644 --- a/drivers/SmartThings/harman-luxury/src/disco.lua +++ b/drivers/SmartThings/harman-luxury/src/disco.lua @@ -7,57 +7,63 @@ local disco_helper = require "disco_helper" local devices = require "devices" local const = require "constants" -local Discovery = { - joined_device = {}, -} +local Discovery = {} -local function update_device_discovery_cache(driver, dni, params, token) +local joined_device = {} + +function Discovery.set_device_field(driver, device) + log.info(string.format("set_device_field : dni=%s", device.device_network_id)) + local device_cache_value = driver.datastore.discovery_cache[device.device_network_id] + + -- persistent fields + device:set_field(const.IP, device_cache_value.ip, { + persist = true, + }) + device:set_field(const.CREDENTIAL, device_cache_value.credential, { + persist = true, + }) + device:set_field(const.DEVICE_INFO, device_cache_value.device_info, { + persist = true, + }) + + driver.datastore.discovery_cache[device.device_network_id] = nil +end + +local function update_device_discovery_cache(driver, dni, params, credential) log.info(string.format("update_device_discovery_cache for device dni: dni=%s, ip=%s", dni, params.ip)) local device_info = devices.get_device_info(dni, params) driver.datastore.discovery_cache[dni] = { ip = params.ip, + credential = credential, device_info = device_info, - credential = token, } end local function try_add_device(driver, device_dni, device_params) log.trace(string.format("try_add_device : dni=%s, ip=%s", device_dni, device_params.ip)) - local token, err = api.InitCredentialsToken(device_params.ip) + local credential, err = api.init_credential_token(device_params.ip) - if err then - log.error(string.format("failed to get credential token for dni=%s, ip=%s", device_dni, device_params.ip)) + if not credential or err then + log.error(string.format("failed to get credential. dni= %s, ip= %s. Error: %s", device_dni, device_params.ip, err)) + joined_device[device_dni] = nil return false end - update_device_discovery_cache(driver, device_dni, device_params, token) + update_device_discovery_cache(driver, device_dni, device_params, credential) driver:try_create_device(driver.datastore.discovery_cache[device_dni].device_info) return true end -function Discovery.set_device_field(driver, device) - log.info(string.format("set_device_field : dni=%s", device.device_network_id)) - local device_cache_value = driver.datastore.discovery_cache[device.device_network_id] +function Discovery.device_added(driver, device) + log.info(string.format("Device added: %s", device.label)) - -- persistent fields - device:set_field(const.STATUS, true, { - persist = true, - }) - device:set_field(const.IP, device_cache_value.ip, { - persist = true, - }) - device:set_field(const.DEVICE_INFO, device_cache_value.device_info, { - persist = true, - }) - if device_cache_value.credential then - device:set_field(const.CREDENTIAL, device_cache_value.credential, { - persist = true, - }) - end + Discovery.set_device_field(driver, device) + joined_device[device.device_network_id] = nil + driver.lifecycle_handlers.init(driver, device) end -local function find_params_table() +function Discovery.find_params_table() log.info("Discovery.find_params_table") local discovery_responses = mdns.discover(const.SERVICE_TYPE, const.DOMAIN) or {} @@ -68,8 +74,6 @@ local function find_params_table() end local function discovery_device(driver) - local unknown_discovered_devices = {} - local known_discovered_devices = {} local known_devices = {} log.debug("\n\n--- Initialising known devices list ---\n") @@ -78,54 +82,23 @@ local function discovery_device(driver) end log.debug("\n\n--- Creating the parameters table ---\n") - local params_table = find_params_table() + local params_table = Discovery.find_params_table() - log.debug("\n\n--- Checking if devices are known or not ---\n") + log.debug("\n\n--- Adding unknown devices ---\n") for dni, params in pairs(params_table) do - if next(known_devices) == nil or not known_devices[dni] then - unknown_discovered_devices[dni] = params + if not known_devices or not known_devices[dni] then log.info(string.format("discovery_device unknown dni=%s, ip=%s", dni, params.ip)) - else - known_discovered_devices[dni] = params - log.info(string.format("discovery_device known dni=%s, ip=%s", dni, params.ip)) - end - end - - log.debug("\n\n--- Update devices cache ---\n") - for dni, params in pairs(known_discovered_devices) do - log.trace(string.format("known dni=%s, ip=%s", dni, params.ip)) - if Discovery.joined_device[dni] then - update_device_discovery_cache(driver, dni, params) - Discovery.set_device_field(driver, known_devices[dni]) - end - end - - if unknown_discovered_devices then - log.debug("\n\n--- Try to create unkown devices ---\n") - for dni, ip in pairs(unknown_discovered_devices) do - log.trace(string.format("unknown dni=%s, ip=%s", dni, ip)) - if not Discovery.joined_device[dni] then + if not joined_device[dni] then if try_add_device(driver, dni, params_table[dni]) then - Discovery.joined_device[dni] = true + joined_device[dni] = true end end + else + log.info(string.format("discovery_device known dni=%s, ip=%s", dni, params.ip)) end end end -function Discovery.find_ip_table() - log.info("Discovery.find_ip_table") - - local dni_params_table = find_params_table() - - local dni_ip_table = {} - for dni, params in pairs(dni_params_table) do - dni_ip_table[dni] = params.ip - end - - return dni_ip_table -end - function Discovery.discovery_handler(driver, _, should_continue) log.info("Starting Harman Luxury discovery") diff --git a/drivers/SmartThings/harman-luxury/src/init.lua b/drivers/SmartThings/harman-luxury/src/init.lua index 47f09161cf..58b9e6ec96 100644 --- a/drivers/SmartThings/harman-luxury/src/init.lua +++ b/drivers/SmartThings/harman-luxury/src/init.lua @@ -338,8 +338,8 @@ end local driver = Driver("Harman Luxury", { discovery = discovery.discovery_handler, lifecycle_handlers = { + added = discovery.device_added, init = device_init, - added = device_added, removed = device_removed, infoChanged = device_changeInfo, }, From b8073c14ec0d54a0a7cc73c0f85e9201ce3a849d Mon Sep 17 00:00:00 2001 From: alon-tchelet Date: Tue, 25 Jun 2024 17:46:18 +0200 Subject: [PATCH 7/8] change communications from REST HTTP API to WebSockets The REST HTTP API polling was creating too much conjunction and was unreliable at time (especially in weaker networks). Hence, we changed the whole communication set up between the hub and the device to WebSockets. With the exception of fetching the device information at discovery, the rest of the communication is done through a WebSocket. We implemented a JSON protocol that tries to replicate the native SmartThings capabilities' table structures and the offline/online logic is now handled fully by the WebSocket connection status. Moreover, a lot of old and no longer used functions and namings have been modified or deleted altogether. Signed-off-by: alon-tchelet --- .../harman-luxury/src/constants.lua | 19 +- .../SmartThings/harman-luxury/src/disco.lua | 25 ++ .../harman-luxury/src/handlers.lua | 239 ----------- .../harman-luxury/src/hl_websocket.lua | 298 +++++++++++++ .../SmartThings/harman-luxury/src/init.lua | 403 +++++------------- 5 files changed, 434 insertions(+), 550 deletions(-) delete mode 100644 drivers/SmartThings/harman-luxury/src/handlers.lua create mode 100644 drivers/SmartThings/harman-luxury/src/hl_websocket.lua diff --git a/drivers/SmartThings/harman-luxury/src/constants.lua b/drivers/SmartThings/harman-luxury/src/constants.lua index d6524150e4..29e3cfab8c 100644 --- a/drivers/SmartThings/harman-luxury/src/constants.lua +++ b/drivers/SmartThings/harman-luxury/src/constants.lua @@ -3,14 +3,20 @@ local Constants = { IP = "device_ipv4", DEVICE_INFO = "device_info", CREDENTIAL = "credential", - STATUS = "status", - HEALTH_TIMER = "health_timer", - UPDATE_TIMER = "value_updates_timer", + INITIALISED = "initialised", + WEBSOCKET = "websocket", + + -- message fields + MESSAGE = "message", + CAPABILITY = "capability", + COMMAND = "command", + ARG = "arg", -- intervals constants (in seconds) - UPDATE_INTERVAL = 1, - HEALTH_CHEACK_INTERVAL = 10, - HTTP_TIMEOUT = 10, + WS_SOCKET_TIMEOUT = 10, + WS_IDLE_PING_PERIOD = 30, + WS_RECONNECT_PERIOD = 10, + HTTP_TIMEOUT = 5, -- discovery constants SERVICE_TYPE = "_sue-st._tcp", @@ -28,5 +34,6 @@ local Constants = { -- general consts VOL_STEP = 5, + WS_PORT = 50002, } return Constants diff --git a/drivers/SmartThings/harman-luxury/src/disco.lua b/drivers/SmartThings/harman-luxury/src/disco.lua index 6044276ada..73020d72d9 100644 --- a/drivers/SmartThings/harman-luxury/src/disco.lua +++ b/drivers/SmartThings/harman-luxury/src/disco.lua @@ -109,4 +109,29 @@ function Discovery.discovery_handler(driver, _, should_continue) log.info("Ending Harman Luxury discovery") end +function Discovery.update_device_ip(device) + local dni = device.device_network_id + local ip = device:get_device_info(const.IP) + + -- collect current parameters + local params_table = Discovery.find_params_table() + + -- update device IPs + if params_table[dni] then + -- if device is still online + local current_ip = params_table[dni].ip + if ip ~= current_ip then + device:set_field(const.IP, current_ip, { + persist = true, + }) + log.info(string.format("%s updated IP from %s to %s", dni, ip, current_ip)) + end + return true + else + -- if device is no longer online + device:offline() + return false + end +end + return Discovery diff --git a/drivers/SmartThings/harman-luxury/src/handlers.lua b/drivers/SmartThings/harman-luxury/src/handlers.lua deleted file mode 100644 index 7401749be1..0000000000 --- a/drivers/SmartThings/harman-luxury/src/handlers.lua +++ /dev/null @@ -1,239 +0,0 @@ -local capabilities = require "st.capabilities" -local api = require "api.apis" -local log = require "log" -local const = require "constants" - -local Handler = {} - ---- handler of switch.on -function Handler.handle_on(_, device, _) - log.info("Starting handle_on") - -- send API switch on message - local ip = device:get_field(const.IP) - local val, err = api.SetOn(ip) - if val then - device:emit_event(capabilities.switch.switch.on()) - else - log.warn(string.format("Error during handle_on(): %s", err)) - end -end - ---- handler of switch.off -function Handler.handle_off(_, device, _) - log.info("Starting handle_off") - -- send API switch off message - local ip = device:get_field(const.IP) - local val, err = api.SetOff(ip) - if val then - device:emit_event(capabilities.switch.switch.off()) - device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) - else - log.warn(string.format("Error during handle_off(): %s", err)) - end -end - ---- internal function to set mute ----@param Device ----@param mute boolean ----@param func_name string -local function set_mute(device, mute, func_name) - local ip = device:get_field(const.IP) - local val, err = api.SetMute(ip, mute) - if val then - if mute then - device:emit_event(capabilities.audioMute.mute.muted()) - else - device:emit_event(capabilities.audioMute.mute.unmuted()) - end - else - log.warn(string.format("Error during %s(): %s", func_name, err)) - end -end - ---- handler of audioMute.mute -function Handler.handle_mute(_, device, _) - log.info("Starting handle_mute") - -- send API mute on message - set_mute(device, true, "handle_mute") -end - ---- handler of audioMute.unmute -function Handler.handle_unmute(_, device, _) - log.info("Starting handle_unmute") - -- send API mute off message - set_mute(device, false, "handle_unmute") -end - ---- handler of audioMute.setMute -function Handler.handle_set_mute(_, device, cmd) - log.info("Starting handle_set_mute") - -- send API mute set message - local mute = cmd.args and cmd.args.state == "muted" - set_mute(device, mute, "handle_set_mute") -end - ---- internal function to set volume ----@param device Device ----@param vol number|nil ----@param step number|nil ----@param func_name string -local function set_vol(device, vol, step, func_name) - local ip = device:get_field(const.IP) - local setVol - if vol then - setVol = vol - else - local currVol, err = api.GetVol(ip) - if err or type(currVol) ~= "number" then - currVol = device:get_latest_state("main", capabilities.audioVolume.ID, capabilities.audioVolume.volume.NAME) - end - setVol = currVol + step - end - local val, err = api.SetVol(ip, setVol) - if val then - device:emit_event(capabilities.audioVolume.volume(setVol)) - else - log.warn(string.format("Error during %s(): %s", func_name, err)) - end -end - ---- handler of audioVolume.volumeUp -function Handler.handle_volume_up(_, device, _) - log.info("Starting handle_volume_up") - -- send API volume get message to know to what volume to raise - set_vol(device, nil, const.VOL_STEP, "handle_volume_up") -end - ---- handler of audioVolume.volumeDown -function Handler.handle_volume_down(_, device, _) - log.info("Starting handle_volume_down") - -- send API volume get message to know to what volume to decrease - set_vol(device, nil, -const.VOL_STEP, "handle_volume_down") -end - ---- handler of audioVolume.setVolume -function Handler.handle_set_volume(_, device, cmd) - log.info("Starting handle_set_volume") - -- send API volume set message - set_vol(device, cmd.args.volume, nil, "handle_set_volume") -end - ---- handler of mediaInputSource.setInputSource -function Handler.handle_setInputSource(_, device, cmd) - log.info("Starting handle_setInputSource") - -- send API input source set message - local ip = device:get_field(const.IP) - local val, err = api.SetInputSource(ip, cmd.args.mode) - if val then - device:emit_event(capabilities.mediaInputSource.inputSource(cmd.args.mode)) - else - log.warn(string.format("Error during handle_setInputSource(): %s", err)) - end -end - ---- handler of mediaPresets.playPreset -function Handler.handle_play_preset(_, device, cmd) - log.info("Starting handle_play_preset") - -- send API to play media preset - local ip = device:get_field(const.IP) - local presetId = cmd.args.presetId:lower():gsub("preset", ""):gsub("%W", "") - local mediaPresets = device:get_latest_state("main", capabilities.mediaPresets.ID, - capabilities.mediaPresets.presets.NAME) - for _, preset in pairs(mediaPresets) do - local id = preset.id - local name = preset.name:lower():gsub("preset", ""):gsub("%W", "") - if id == presetId or name == presetId then - local _, err = api.PlayMediaPreset(ip, id) - if err then - log.warn(string.format("Error during handle_play_preset(): %s", err)) - end - return - end - end - log.warn(string.format("Couldn't find provided Media Preset: %s", cmd.args.presetId)) -end - ---- handler of audioNotification.playTrack, audioNotification.playTrackAndResume, ---- and audioNotification.playTrackAndRestore -function Handler.handle_audio_notification(_, device, cmd) - log.info("Starting handle_audio_notification") - -- send API to play audio notification - local ip = device:get_field(const.IP) - local uri, level = cmd.args.uri, cmd.args.level - local _, err = api.SendAudioNotification(ip, uri, level) - if err then - log.warn(string.format("Error during handle_audio_notification(): %s", err)) - end -end - ---- internal function to handle set playback status ----@param device Device ----@param status string ----@param func_name string -local function set_playback_status(device, status, func_name) - local invokeFunc = { - pause = api.InvokePause, - play = api.InvokePlay, - stop = api.InvokeStop, - } - if invokeFunc[status] == nil then - log.warn(string.format("Error during %s(): unsupported status given - %s", func_name, status)) - return - else - local ip = device:get_field(const.IP) - local _, err = invokeFunc[status](ip) - if err then - log.warn(string.format("Error during %s(): %s", func_name, err)) - end - end -end - ---- handler of mediaPlayback.play -function Handler.handle_play(_, device, _) - log.info("Starting handle_play") - set_playback_status(device, "play", "handle_play") -end - ---- handler of mediaPlayback.pause -function Handler.handle_pause(_, device, _) - log.info("Starting handle_pause") - set_playback_status(device, "pause", "handle_pause") -end - ---- handler of mediaPlayback.stop -function Handler.handle_stop(_, device, _) - log.info("Starting handle_stop") - set_playback_status(device, "stop", "handle_stop") -end - ---- handler of mediaTrackControl.nextTrack -function Handler.handle_next_track(_, device, _) - log.info("Starting handle_next_track") - local ip = device:get_field(const.IP) - local _, err = api.InvokeNext(ip) - if err then - log.warn(string.format("Error during handle_next_track(): %s", err)) - end -end - ---- handler of mediaTrackControl.previousTrack -function Handler.handle_previous_track(_, device, _) - log.info("Starting handle_previous_track") - local ip = device:get_field(const.IP) - local _, err = api.InvokePrevious(ip) - if err then - log.warn(string.format("Error during handle_previous_track(): %s", err)) - end -end - ---- handler of keypadInput.sendKey -function Handler.handle_send_key(_, device, cmd) - log.info(string.format("Starting handle_send_key. Input key is: %s", cmd.args.keyCode)) - local ip = device:get_field(const.IP) - local _, err = api.InvokeSendKey(ip, cmd.args.keyCode) - if err then - log.warn(string.format("Error during handle_send_key(): %s", err)) - end -end - -return Handler diff --git a/drivers/SmartThings/harman-luxury/src/hl_websocket.lua b/drivers/SmartThings/harman-luxury/src/hl_websocket.lua new file mode 100644 index 0000000000..0d7b99d96b --- /dev/null +++ b/drivers/SmartThings/harman-luxury/src/hl_websocket.lua @@ -0,0 +1,298 @@ +local log = require "log" + +local CloseCode = require "lustre.frame.close".CloseCode +local Config = require "lustre".Config +local ws = require "lustre".WebSocket + +local cosock = require "cosock" +local socket = require "cosock.socket" +local capabilities = require "st.capabilities" +local json = require "st.json" +local st_utils = require "st.utils" + +local api = require "api.apis" +local const = require "constants" +local discovery = require "disco" + +--- a websocket to get updates from Harman Luxury devices +--- @class harman-luxury.HLWebsocket +--- @field driver table the driver the device is a memeber of +--- @field device table the device the websocket is connected to +--- @field websocket table|nil the websocket connection to the device +local HLWebsocket = {} +HLWebsocket.__index = HLWebsocket + +--- handles capabilities and sends the commands to the device +---@param msg any device that sends the command +function HLWebsocket:send_msg_handler(msg) + local dni = self.device.device_network_id + log.debug(string.format("Sending this message to %s: %s", dni, st_utils.stringify_table(msg))) + self.websocket:send_text(msg) +end + +--- handles listener event messages to update relevant SmartThings capbilities +---@param msg any|table +function HLWebsocket:received_msg_handler(msg) + if msg[const.CREDENTIAL] then + -- the device updates all WebSockets when it registers a new credential token. If this hub no longer holds the token + -- disconnect it + local currentToken = self.device:get_field(const.CREDENTIAL) + if msg[const.CREDENTIAL] ~= currentToken then + local dni = self.device.device_network_id + log.info(string.format("%s is connected to a different hub. Setting this device offline in this hub", dni)) + self:stop() + return + end + end + -- check for a power state change + if msg[capabilities.switch.ID] then + local powerState = msg[capabilities.switch.ID] + if powerState == capabilities.switch.commands.on.NAME then + self.device:emit_event(capabilities.switch.switch.on()) + elseif powerState == capabilities.switch.commands.off.NAME then + self.device:emit_event(capabilities.switch.switch.off()) + end + end + -- check for a player state change + if msg[capabilities.mediaPlayback.ID] then + local playerState = msg[capabilities.mediaPlayback.ID] + if playerState == capabilities.mediaPlayback.commands.play.NAME then + self.device:emit_event(capabilities.mediaPlayback.playbackStatus.playing()) + elseif playerState == capabilities.mediaPlayback.commands.pause.NAME then + self.device:emit_event(capabilities.mediaPlayback.playbackStatus.paused()) + else + self.device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) + local stopTrackData = {} + stopTrackData["title"] = "" + self.device:emit_event(capabilities.audioTrackData.audioTrackData(stopTrackData)) + self.device:emit_event(capabilities.audioTrackData.totalTime(0)) + end + end + -- check for an audio track data change + if msg[capabilities.audioTrackData.ID] then + local audioTrackData = msg[capabilities.audioTrackData.ID].audioTrackData + local totalTime = msg[capabilities.audioTrackData.ID].totalTime + local trackdata = {} + if type(audioTrackData.title) == "string" then + trackdata.title = audioTrackData.title + else + trackdata.title = "" + end + if type(audioTrackData.artist) == "string" then + trackdata.artist = audioTrackData.artist + end + if type(audioTrackData.album) == "string" then + trackdata.album = audioTrackData.album + end + if type(audioTrackData.albumArtUrl) == "string" then + trackdata.albumArtUrl = audioTrackData.albumArtUrl + end + if type(audioTrackData.mediaSource) == "string" then + trackdata.mediaSource = audioTrackData.mediaSource + end + self.device:emit_event(capabilities.audioTrackData.audioTrackData(trackdata)) + self.device:emit_event(capabilities.audioTrackData.totalTime(totalTime or 0)) + end + -- check for an elapsed time change + if msg[capabilities.audioTrackData.elapsedTime.NAME] then + self.device:emit_event(capabilities.audioTrackData.elapsedTime(msg[capabilities.audioTrackData.ID])) + end + -- check for a media presets change + if msg[capabilities.mediaPresets.ID] and type(msg[capabilities.mediaPresets.ID]) == "table" then + self.device:emit_event(capabilities.mediaPresets.presets(msg[capabilities.mediaPresets.ID])) + end + -- check for a supported input sources change + if msg["supportedInputSources"] then + self.device:emit_event(capabilities.mediaInputSource.supportedInputSources(msg["supportedInputSources"])) + end + -- check for a supportedInputSources change + if msg["supportedTrackControlCommands"] then + self.device:emit_event(capabilities.mediaTrackControl.supportedTrackControlCommands( + msg["supportedTrackControlCommands"]) or {}) + end + -- check for a supported playback commands change + if msg["supportedPlaybackCommands"] then + self.device:emit_event(capabilities.mediaPlayback.supportedPlaybackCommands(msg["supportedPlaybackCommands"]) or + {}) + end + -- check for a media input source change + if msg[capabilities.mediaInputSource.ID] then + self.device:emit_event(capabilities.mediaInputSource.inputSource(msg[capabilities.mediaInputSource.ID])) + end + -- check for a volume value change + if msg[capabilities.audioVolume.ID] then + self.device:emit_event(capabilities.audioVolume.volume(msg[capabilities.audioVolume.ID])) + end + -- check for a mute value change + if msg[capabilities.audioMute.ID] ~= nil then + if msg[capabilities.audioMute.ID] then + self.device:emit_event(capabilities.audioMute.mute.muted()) + else + self.device:emit_event(capabilities.audioMute.mute.unmuted()) + end + end +end + +--- socket listener +function HLWebsocket:listener() + local device_dni = self.device.device_network_id + while not self._stopped do + if self.websocket then + local msg, err + msg, err = self.websocket:receive() + if err ~= nil and err ~= "closed" then + -- unknown error. try reconnect and kill cosock task to avoid more than one listener task for the same device + log.err(string.format("%s Websocket error: %s", device_dni, err)) + self.device:offline() + cosock.spawn(self:try_reconnect(), string.format("%s try_reconnect", device_dni)) + return + elseif err == "closed" then + -- WebSocket closed. try reconnect and kill cosock task to avoid more than one listener task for the same device + log.info(string.format("%s Websocket closed: %s", device_dni, err)) + self.websocket = nil + cosock.spawn(self:try_reconnect(), string.format("%s try_reconnect", device_dni)) + return + else + -- handle received message + log.trace(string.format("%s received websocket message: %s", device_dni, st_utils.stringify_table(msg))) + local jsonMsg = json.decode(msg.data) + self:received_msg_handler(jsonMsg) + end + else + cosock.spawn(self:try_reconnect(), string.format("%s try_reconnect", device_dni)) + return + end + end +end + +--- try reconnect webclient +---@param attempts integer|nil reconnect attempt number (default=0) +---@return boolean has the reconnection succeeded +function HLWebsocket:try_reconnect(attempts) + attempts = 0 or attempts + local retries = 0 + local dni = self.device.device_network_id + local ip = self.device:get_field(const.IP) + local token = self.device:get_field(const.CREDENTIAL) + + if not ip then + log.warn(string.format("%s cannot reconnect because no device ip", dni)) + return false + end + + log.trace(string.format("%s checking if IP are still up to date", dni)) + local activeToken, err = api.GetActiveCredentialToken(ip) + if err then + -- device is either offline or changed IP. try to update IP then try reconnect again + log.warn(string.format("%s error while getting active credential: Error: ", dni, err)) + if attempts < 3 then + discovery.update_active_devices_ips(self.device) + return self:try_reconnect(attempts + 1) + else + return false + end + end + + log.trace(string.format("%s checking if hub is still active", dni)) + if token ~= activeToken then + -- hub no longer active. stop device onn this hub + self:stop() + return false + end + + log.info(string.format("%s attempting to reconnect websocket for speaker at %s", dni, ip)) + while true do + if self:start() then + return true + end + retries = retries + 1 + log.info(string.format("Reconnect attempt %s in %s seconds", retries, const.WS_RECONNECT_PERIOD)) + socket.sleep(const.WS_RECONNECT_PERIOD) + end +end + +--- functionto start the websocket connection +--- @return boolean boolean +function HLWebsocket:start() + local dni = self.device.device_network_id + local ip = self.device:get_field(const.IP) + if not ip then + log.error(string.format("Failed to start %s websocket connection, no ip address for device", dni)) + return false + end + + log.info(string.format("%s starting websocket client on %s", dni, ip)) + local sock, err = socket.tcp() + if not sock or err ~= nil then + log.error(string.format("%s Could not open TCP socket: %s", dni, err)) + return false + end + + local _ + _, err = sock:settimeout(const.WS_SOCKET_TIMEOUT) + if err ~= nil then + log.warn(string.format("%s Socket set timeout error: %s", dni, err)) + return false + end + + local config = Config.default():keep_alive(const.WS_IDLE_PING_PERIOD) + local websocket = ws.client(sock, "/", config) + _, err = websocket:connect(ip, const.WS_PORT) + if err then + log.error(string.format("%s failed to connect websocket: %s", dni, err)) + self.device:offline() + return false + end + + log.info(string.format("%s Connected websocket successfully", dni)) + self._stopped = false + self.websocket = websocket + self.device:online() + + log.trace(string.format("%s Refreshing all values after successful WebSocket connection", dni)) + self.driver:inject_capability_command(self.device, { + capability = capabilities.refresh.ID, + command = capabilities.refresh.commands.refresh.NAME, + args = {}, + }) + + log.trace(string.format("%s Started websocket listener", dni)) + cosock.spawn(self:listener(), string.format("%s listener", dni)) + + return true +end + +--- creates a Harman Luxury websocket object for the device +---@param driver any +---@param device any +---@return HLWebsocket +function HLWebsocket.create_device_websocket(driver, device) + return setmetatable({ + device = device, + driver = driver, + _stopped = true, + }, HLWebsocket) +end + +--- stops webclient +function HLWebsocket:stop() + local dni = self.device.device_network_id + self.device:offline() + self._stopped = true + if not self.websocket then + log.warn(string.format("%s no websocket exists to close", dni)) + return + end + local suc, err = self.websocket:close(CloseCode.normal()) + if not suc then + log.error(string.format("%s failed to close websocket: %s", dni, err)) + end +end + +--- tests if the websocket connection is stopped or not +--- @return boolean isStopped +function HLWebsocket:is_stopped() + return self._stopped +end + +return HLWebsocket diff --git a/drivers/SmartThings/harman-luxury/src/init.lua b/drivers/SmartThings/harman-luxury/src/init.lua index 58b9e6ec96..2bc354a1ca 100644 --- a/drivers/SmartThings/harman-luxury/src/init.lua +++ b/drivers/SmartThings/harman-luxury/src/init.lua @@ -4,12 +4,14 @@ -- SmartThings inclusions local Driver = require "st.driver" local capabilities = require "st.capabilities" -local st_utils = require "st.utils" +local json = require "st.json" local log = require "log" +local socket = require "cosock.socket" +local cosock = require "cosock" -- local Harman Luxury inclusions local discovery = require "disco" -local handlers = require "handlers" +local hlws = require "hl_websocket" local api = require "api.apis" local const = require "constants" @@ -17,304 +19,113 @@ local const = require "constants" -- Device Functions ---------------------------------------------------------- -local function stop_check_for_updates_thread(device) - local current_timer = device:get_field(const.UPDATE_TIMER) - if current_timer ~= nil then - log.info(string.format("create_check_for_updates_thread: dni=%s, remove old timer", device.device_network_id)) - device.thread:cancel_timer(current_timer) - end -end - -local function device_removed(_, device) - log.info("Device removed") - -- cancel timers - stop_check_for_updates_thread(device) -end - -local function goOffline(device) - stop_check_for_updates_thread(device) - if device:get_field(const.STATUS) then - device:emit_event(capabilities.switch.switch.off()) - device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) - device:emit_event(capabilities.audioTrackData.audioTrackData({ - title = "", - })) - end - device:set_field(const.STATUS, false, { - persist = true, - }) - device:offline() -end - -local function refresh(_, device) - local ip = device:get_field(const.IP) - - -- check and update device status - local power_state - power_state, _ = api.GetPowerState(ip) - if power_state then - log.debug(string.format("Current power state: %s", power_state)) +--- handler that builds the JSON to send to the device through the WebSocket +---@param device any +---@param cmd any +local function message_sender(_, device, cmd) + local msg, value = {}, {} + local device_ws = device:get_field(const.WEBSOCKET) + local token = device:get_field(const.CREDENTIAL) - if power_state == "online" then - device:emit_event(capabilities.switch.switch.on()) - local player_state, audioTrackData + value[const.CAPABILITY] = cmd.capability or nil + value[const.COMMAND] = cmd.command or nil + value[const.ARG] = cmd.args or nil + msg[const.MESSAGE] = value + msg[const.CREDENTIAL] = token - -- get player state - player_state, _ = api.GetPlayerState(ip) - if player_state then - if player_state == "playing" then - device:emit_event(capabilities.mediaPlayback.playbackStatus.playing()) - elseif player_state == "paused" then - device:emit_event(capabilities.mediaPlayback.playbackStatus.paused()) - else - device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) - end - end - - -- get audio track data - audioTrackData, _ = api.getAudioTrackData(ip) - if audioTrackData then - device:emit_event(capabilities.audioTrackData.audioTrackData(audioTrackData.trackdata)) - device:emit_event(capabilities.mediaPlayback.supportedPlaybackCommands( - audioTrackData.supportedPlaybackCommands)) - device:emit_event(capabilities.mediaTrackControl.supportedTrackControlCommands( - audioTrackData.supportedTrackControlCommands)) - device:emit_event(capabilities.audioTrackData.totalTime(audioTrackData.totalTime or 0)) - end - elseif device:get_field(const.STATUS) then - device:emit_event(capabilities.switch.switch.off()) - device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) - end - end + msg = json.encode(msg) - -- get media presets list - local presets - presets, _ = api.GetMediaPresets(ip) - if presets then - device:emit_event(capabilities.mediaPresets.presets(presets)) - end + device_ws:send_msg_handler(msg) +end - -- check and update device volume and mute status - local vol, mute - vol, _ = api.GetVol(ip) - if vol then - device:emit_event(capabilities.audioVolume.volume(vol)) - end - mute, _ = api.GetMute(ip) - if type(mute) == "boolean" then - if mute then - device:emit_event(capabilities.audioMute.mute.muted()) - else - device:emit_event(capabilities.audioMute.mute.unmuted()) +--- ensure used presetId is really the Preset ID. +-- When a user sets a preset selection from routines they can insert a custom string. +-- Here we try to guess if the string matches any of the existing presets +local function do_play_preset(_, device, cmd) + log.info(string.format("Starting do_play_preset: %s", device.label)) + -- send API to play media preset + local presetId = cmd.args.presetId:lower():gsub("preset", ""):gsub("%W", "") + local mediaPresets = device:get_latest_state("main", capabilities.mediaPresets.ID, + capabilities.mediaPresets.presets.NAME) + for _, preset in pairs(mediaPresets) do + local id = preset.id + local name = preset.name:lower():gsub("preset", ""):gsub("%W", "") + if id == presetId or name == presetId then + cmd.args.presetId = id + message_sender(_, device, cmd) + return end end - - -- check and update device media input source - local inputSource - inputSource, _ = api.GetInputSource(ip) - if inputSource then - device:emit_event(capabilities.mediaInputSource.inputSource(inputSource)) - end + log.warn(string.format("Couldn't find provided Media Preset: %s", cmd.args.presetId)) end -local function check_for_updates(device) - log.trace(string.format("%s, checking if device values changed", device.device_network_id)) - local ip = device:get_field(const.IP) - local changes, err = api.InvokeGetUpdates(ip) - -- check if changes is empty - if not err then - log.debug(string.format("changes: %s", st_utils.stringify_table(changes))) - if type(changes) ~= "table" then - log.warn("check_for_updates: Received value was not a table (JSON). Likely an error occured") - return - end - -- check if there are any changes - local next = next - if next(changes) ~= nil then - -- check for a power state change - if changes["powerState"] then - local powerState = changes["powerState"] - if powerState == "online" then - device:emit_event(capabilities.switch.switch.on()) - elseif powerState == "offline" then - device:emit_event(capabilities.switch.switch.off()) - end - end - -- check for a player state change - if changes["playerState"] then - local playerState = changes["playerState"] - if playerState == "playing" then - device:emit_event(capabilities.mediaPlayback.playbackStatus.playing()) - elseif playerState == "paused" then - log.debug("playerState - changed to paused") - device:emit_event(capabilities.mediaPlayback.playbackStatus.paused()) - else - device:emit_event(capabilities.mediaPlayback.playbackStatus.stopped()) - end - end - -- check for a audio track data change - if changes["audioTrackData"] then - local audioTrackData = changes["audioTrackData"] - local trackdata = {} - if type(audioTrackData.title) == "string" then - trackdata.title = audioTrackData.title - else - trackdata.title = "" - end - if type(audioTrackData.artist) == "string" then - trackdata.artist = audioTrackData.artist - end - if type(audioTrackData.album) == "string" then - trackdata.album = audioTrackData.album - end - if type(audioTrackData.albumArtUrl) == "string" then - trackdata.albumArtUrl = audioTrackData.albumArtUrl - end - if type(audioTrackData.mediaSource) == "string" then - trackdata.mediaSource = audioTrackData.mediaSource - end - -- if track changed - device:emit_event(capabilities.audioTrackData.audioTrackData(trackdata)) +--- checks the health of the websocket connection and triggers the device to send all current values +local function do_refresh(_, device, cmd) + log.info(string.format("Starting do_refresh: %s", device.label)) - device:emit_event(capabilities.mediaPlayback.supportedPlaybackCommands( - audioTrackData.supportedPlaybackCommands) or {"play", "stop", "pause"}) - device:emit_event(capabilities.mediaTrackControl.supportedTrackControlCommands( - audioTrackData.supportedTrackControlCommands) or {"nextTrack", "previousTrack"}) - device:emit_event(capabilities.audioTrackData.totalTime(audioTrackData.totalTime or 0)) - end - -- check for a audio track data change - if changes["elapsedTime"] then - device:emit_event(capabilities.audioTrackData.elapsedTime(changes["elapsedTime"])) - end - -- check for a media presets change - if changes["mediaPresets"] and type(changes["mediaPresets"].presets) == "table" then - device:emit_event(capabilities.mediaPresets.presets(changes["mediaPresets"].presets)) - end - -- check for a media input source change - if changes["mediaInputSource"] then - device:emit_event(capabilities.mediaInputSource.inputSource(changes["mediaInputSource"])) - end - -- check for a volume value change - if changes["volume"] then - device:emit_event(capabilities.audioVolume.volume(changes["volume"])) - end - -- check for a mute value change - if changes["mute"] ~= nil then - if changes["mute"] then - device:emit_event(capabilities.audioMute.mute.muted()) - else - device:emit_event(capabilities.audioMute.mute.unmuted()) - end + -- restart websocket if needed + local device_ws = device:get_field(const.WEBSOCKET) + if device_ws then + if device_ws.websocket == nil then + device.log.info("Trying to restart websocket client for device updates") + device_ws:stop() + socket.sleep(1) -- give time for Lustre to close the websocket + if not device_ws:start() then + log.warn(string.format("%s failed to restart listening websocket client for device updates", + device.device_network_id)) + return end end + message_sender(_, device, cmd) end end -local function create_check_for_updates_thread(device) - -- stop old timer if one exists - stop_check_for_updates_thread(device) - - log.info(string.format("create_check_for_updates_thread: dni=%s", device.device_network_id)) - local new_timer = device.thread:call_on_schedule(const.UPDATE_INTERVAL, function() - check_for_updates(device) - end, "value_updates_timer") - device:set_field(const.UPDATE_TIMER, new_timer) -end - local function device_init(driver, device) log.info(string.format("Initiating device: %s", device.label)) - local device_ip = device:get_field(const.IP) + if device:get_field(const.INITIALISED) then + log.info(string.format("device_init : already initialized. dni = %s", device.device_network_id)) + return + end + local device_dni = device.device_network_id if driver.datastore.discovery_cache[device_dni] then - log.warn("set unsaved device field") + log.info("set unsaved device field") discovery.set_device_field(driver, device) end - -- set supported default media playback commands - device:emit_event(capabilities.mediaPlayback.supportedPlaybackCommands( - {capabilities.mediaPlayback.commands.play.NAME, capabilities.mediaPlayback.commands.pause.NAME, - capabilities.mediaPlayback.commands.stop.NAME})) - device:emit_event(capabilities.mediaTrackControl.supportedTrackControlCommands( - {capabilities.mediaTrackControl.commands.nextTrack.NAME, - capabilities.mediaTrackControl.commands.previousTrack.NAME})) - - -- set supported input sources - local supportedInputSources, _ = api.GetSupportedInputSources(device_ip) - device:emit_event(capabilities.mediaInputSource.supportedInputSources(supportedInputSources)) - - log.trace(string.format("device IP: %s", device_ip)) - - create_check_for_updates_thread(device) - - refresh(driver, device) -end - -local function update_connection(driver) - log.debug("Entered update_connection()...") - -- only test connections if there are registered devices - local devices = driver:get_devices() - if next(devices) ~= nil then - local devices_ip_table = discovery.find_ip_table() - for _, device in ipairs(devices) do - local device_dni = device.device_network_id - local device_ip = device:get_field(const.IP) - local current_ip = devices_ip_table[device_dni] - -- check if this device's dni appeared in the scan - if current_ip then - -- update IP associated to this device if changed - if current_ip ~= device_ip then - log.warn(string.format("Harman Luxury Driver updated %s IP to %s", device_dni, current_ip)) - device:set_field(const.IP, current_ip, { - persist = true, - }) - end - -- set device online if credentials still match and update device IP if it changed - local active_token, err = api.GetCredentialsToken(current_ip) - if active_token then - local device_token = device:get_field(const.CREDENTIAL) - if active_token == device_token then - -- if device is going back online after being offline we want to also reinitialize the device - local state = device:get_field(const.STATUS) - if state == false then - device_init(driver, device) - end - device:set_field(const.STATUS, true, { - persist = true, - }) - device:online() - else - log.warn(string.format("device with dni: %s no longer holds the credential token", device_dni)) - goOffline(device) - end - else - log.warn(string.format( - "device with dni: %s had issues while trying to read credentail token. Error message: %s", - device_dni, err)) - goOffline(device) - end + -- start websocket + cosock.spawn(function() + while true do + local device_ws = hlws.create_device_websocket(driver, device) + device:set_field(const.WEBSOCKET, device_ws) + if device_ws:start() then + log.info(string.format("%s successfully connected to websocket", device_dni)) + device:set_field(const.INITIALISED, true, { + persist = false, + }) + break else - -- set device offline if not detected - log.warn(string.format( - "Harman Luxury Driver set %s offline as it didn't appear on latest update connections scan", - device_dni)) - goOffline(device) + log.info(string.format("%s failed to connect to websocket. Trying again in %d seconds", device_dni, + const.WS_RECONNECT_PERIOD)) end + socket.sleep(const.WS_RECONNECT_PERIOD) end - end + end, string.format("%s device_init", device_dni)) end -local function device_added(driver, device) - log.info(string.format("Device added: %s", device.label)) - discovery.set_device_field(driver, device) +local function device_removed(_, device) local device_dni = device.device_network_id - discovery.joined_device[device_dni] = nil - -- ensuring device is initialised - device_init(driver, device) + log.info(string.format("Device removed - dni=\"%s\"", device_dni)) + -- close websocket + local device_ws = device:get_field(const.WEBSOCKET) + if device_ws then + device_ws:stop() + end end local function device_changeInfo(_, device, _, _) - log.info(string.format("Device added: %s", device.label)) + log.info(string.format("Device changed info: %s", device.label)) local ip = device:get_field(const.IP) local _, err = api.SetDeviceName(ip, device.label) if err then @@ -323,13 +134,6 @@ local function device_changeInfo(_, device, _, _) end end -local function do_refresh(driver, device, _) - log.info(string.format("Starting do_refresh: %s", device.label)) - - -- check and update device values - refresh(driver, device) -end - ---------------------------------------------------------- -- Driver Definition ---------------------------------------------------------- @@ -348,38 +152,38 @@ local driver = Driver("Harman Luxury", { [capabilities.refresh.commands.refresh.NAME] = do_refresh, }, [capabilities.switch.ID] = { - [capabilities.switch.commands.on.NAME] = handlers.handle_on, - [capabilities.switch.commands.off.NAME] = handlers.handle_off, + [capabilities.switch.commands.on.NAME] = message_sender, + [capabilities.switch.commands.off.NAME] = message_sender, }, [capabilities.audioMute.ID] = { - [capabilities.audioMute.commands.mute.NAME] = handlers.handle_mute, - [capabilities.audioMute.commands.unmute.NAME] = handlers.handle_unmute, - [capabilities.audioMute.commands.setMute.NAME] = handlers.handle_set_mute, + [capabilities.audioMute.commands.mute.NAME] = message_sender, + [capabilities.audioMute.commands.unmute.NAME] = message_sender, + [capabilities.audioMute.commands.setMute.NAME] = message_sender, }, [capabilities.audioVolume.ID] = { - [capabilities.audioVolume.commands.volumeUp.NAME] = handlers.handle_volume_up, - [capabilities.audioVolume.commands.volumeDown.NAME] = handlers.handle_volume_down, - [capabilities.audioVolume.commands.setVolume.NAME] = handlers.handle_set_volume, + [capabilities.audioVolume.commands.volumeUp.NAME] = message_sender, + [capabilities.audioVolume.commands.volumeDown.NAME] = message_sender, + [capabilities.audioVolume.commands.setVolume.NAME] = message_sender, }, [capabilities.mediaInputSource.ID] = { - [capabilities.mediaInputSource.commands.setInputSource.NAME] = handlers.handle_setInputSource, + [capabilities.mediaInputSource.commands.setInputSource.NAME] = message_sender, }, [capabilities.mediaPresets.ID] = { - [capabilities.mediaPresets.commands.playPreset.NAME] = handlers.handle_play_preset, + [capabilities.mediaPresets.commands.playPreset.NAME] = do_play_preset, }, [capabilities.audioNotification.ID] = { - [capabilities.audioNotification.commands.playTrack.NAME] = handlers.handle_audio_notification, - [capabilities.audioNotification.commands.playTrackAndResume.NAME] = handlers.handle_audio_notification, - [capabilities.audioNotification.commands.playTrackAndRestore.NAME] = handlers.handle_audio_notification, + [capabilities.audioNotification.commands.playTrack.NAME] = message_sender, + [capabilities.audioNotification.commands.playTrackAndResume.NAME] = message_sender, + [capabilities.audioNotification.commands.playTrackAndRestore.NAME] = message_sender, }, [capabilities.mediaPlayback.ID] = { - [capabilities.mediaPlayback.commands.pause.NAME] = handlers.handle_pause, - [capabilities.mediaPlayback.commands.play.NAME] = handlers.handle_play, - [capabilities.mediaPlayback.commands.stop.NAME] = handlers.handle_stop, + [capabilities.mediaPlayback.commands.pause.NAME] = message_sender, + [capabilities.mediaPlayback.commands.play.NAME] = message_sender, + [capabilities.mediaPlayback.commands.stop.NAME] = message_sender, }, [capabilities.mediaTrackControl.ID] = { - [capabilities.mediaTrackControl.commands.nextTrack.NAME] = handlers.handle_next_track, - [capabilities.mediaTrackControl.commands.previousTrack.NAME] = handlers.handle_previous_track, + [capabilities.mediaTrackControl.commands.nextTrack.NAME] = message_sender, + [capabilities.mediaTrackControl.commands.previousTrack.NAME] = message_sender, }, }, supported_capabilities = {capabilities.switch, capabilities.audioMute, capabilities.audioVolume, @@ -387,17 +191,6 @@ local driver = Driver("Harman Luxury", { capabilities.mediaTrackControl, capabilities.refresh}, }) ----------------------------------------------------------- --- Driver Routines ----------------------------------------------------------- - --- create driver IP update routine - -log.info("create health_check_timer for Harman Luxury devices") -driver:call_on_schedule(const.HEALTH_CHEACK_INTERVAL, function() - update_connection(driver) -end, const.HEALTH_TIMER) - ---------------------------------------------------------- -- main ---------------------------------------------------------- From a3dc9a6efaf2369568666f1b37c61dc02ce7d4ce Mon Sep 17 00:00:00 2001 From: alon-tchelet Date: Tue, 25 Jun 2024 17:49:00 +0200 Subject: [PATCH 8/8] fix handleReply function documentation Signed-off-by: alon-tchelet --- drivers/SmartThings/harman-luxury/src/api/nsdk.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drivers/SmartThings/harman-luxury/src/api/nsdk.lua b/drivers/SmartThings/harman-luxury/src/api/nsdk.lua index c18c38782d..e0dbe81bd5 100644 --- a/drivers/SmartThings/harman-luxury/src/api/nsdk.lua +++ b/drivers/SmartThings/harman-luxury/src/api/nsdk.lua @@ -64,7 +64,7 @@ end ---@param func_name string ---@param u string ---@param sink string ----@param code integer +---@param code integer|string ---@param valLocationFunc function ---@return boolean|number|string|table|nil, nil|string local function handleReply(func_name, u, sink, code, valLocationFunc)