From 8fef5c1766191368b65c219e4d3a925d13838e43 Mon Sep 17 00:00:00 2001 From: Benoit Pierre Date: Thu, 1 May 2025 11:25:37 +0200 Subject: [PATCH] calibre: rework connection setup Faster and with better UI: - roughly halve a successful connection setup - drop manual & temporary TCP connection before the real ZMQ one: - it slows down the total connection time - it's actually counted by calibre as a failed connection attempt: after 5 consecutive failures from the same IP, calibre ignore further connections, with no feedback to the user, except an error trace, until the wireless server is restarted - avoid unconditional 1s delay before checking for authentication failure - "searching" & "connecting" (cancellable) dialogs - stop connection on quit / restart --- plugins/calibre.koplugin/main.lua | 34 +-- plugins/calibre.koplugin/wireless.lua | 380 ++++++++++++++++---------- 2 files changed, 244 insertions(+), 170 deletions(-) diff --git a/plugins/calibre.koplugin/main.lua b/plugins/calibre.koplugin/main.lua index ffd35f9a1..25977fe9e 100644 --- a/plugins/calibre.koplugin/main.lua +++ b/plugins/calibre.koplugin/main.lua @@ -37,43 +37,27 @@ function Calibre:onCalibreBrowseBy(field) end function Calibre:onNetworkDisconnected() - self:closeWirelessConnection() + CalibreWireless:disconnect() end function Calibre:onSuspend() - self:closeWirelessConnection() + CalibreWireless:disconnect() end function Calibre:onClose() - self:closeWirelessConnection() + CalibreWireless:disconnect() +end + +function Calibre:onCloseWidget() + CalibreWireless:disconnect() end function Calibre:onStartWirelessConnection() - UIManager:nextTick(function() - UIManager:show(InfoMessage:new{ - text = _("Connecting to calibre"), - timeout = 2, - }) - end) - UIManager:tickAfterNext(function() - self:startWirelessConnection() - end) + CalibreWireless:connect() end function Calibre:onCloseWirelessConnection() - self:closeWirelessConnection() -end - -function Calibre:startWirelessConnection() - if not CalibreWireless.calibre_socket then - CalibreWireless:connect() - end -end - -function Calibre:closeWirelessConnection() - if CalibreWireless.calibre_socket then - CalibreWireless:disconnect() - end + CalibreWireless:disconnect() end function Calibre:onDispatcherRegisterActions() diff --git a/plugins/calibre.koplugin/wireless.lua b/plugins/calibre.koplugin/wireless.lua index 9fcc312e4..c25fafe72 100644 --- a/plugins/calibre.koplugin/wireless.lua +++ b/plugins/calibre.koplugin/wireless.lua @@ -13,6 +13,7 @@ local FFIUtil = require("ffi/util") local InputDialog = require("ui/widget/inputdialog") local InfoMessage = require("ui/widget/infomessage") local NetworkMgr = require("ui/network/manager") +local Trapper = require("ui/trapper") local UIManager = require("ui/uimanager") local WidgetContainer = require("ui/widget/container/widgetcontainer") local lfs = require("libs/libkoreader-lfs") @@ -25,6 +26,58 @@ local T = FFIUtil.template require("ffi/zeromq_h") +-- calibre broadcast ports used to find calibre server +local BROADCAST_PORTS = {54982, 48123, 39001, 44044, 59678} +-- calibre companion local port +local COMPANION_PORT = 8134 +-- requests opcodes +local OPCODES = { + OK = 0, + SET_CALIBRE_DEVICE_INFO = 1, + SET_CALIBRE_DEVICE_NAME = 2, + GET_DEVICE_INFORMATION = 3, + TOTAL_SPACE = 4, + FREE_SPACE = 5, + GET_BOOK_COUNT = 6, + SEND_BOOKLISTS = 7, + SEND_BOOK = 8, + GET_INITIALIZATION_INFO = 9, + BOOK_DONE = 11, + NOOP = 12, + DELETE_BOOK = 13, + GET_BOOK_FILE_SEGMENT = 14, + GET_BOOK_METADATA = 15, + SEND_BOOK_METADATA = 16, + DISPLAY_MESSAGE = 17, + CALIBRE_BUSY = 18, + SET_LIBRARY_INFO = 19, + ERROR = 20, +} + +-- Mark some strings for translation. +-- luacheck: push ignore 511 +if false then + -- @translators tcp socket error on host:port + _("no route to host") + -- @translators tcp socket error on host:port + _("host not found") + -- @translators tcp socket error on host:port + _("connection refused") + -- @translators tcp socket error on host:port + _("timeout") + -- @translators tcp socket error on host:port + _("closed") + -- @translators calibre server address type + _("discovered") + -- @translators calibre server address type + _("specified") + -- @translators calibre connection error + _("handshake timeout") + -- @translators calibre connection error + _("invalid password") +end +-- luacheck: pop + -- supported formats local extensions = CalibreExtensions:get() @@ -56,51 +109,20 @@ local CalibreWireless = WidgetContainer:extend{ id = "KOReader", model = require("device").model, version = require("version"):getCurrentRevision(), - -- calibre companion local port - port = 8134, - -- calibre broadcast ports used to find calibre server - broadcast_ports = {54982, 48123, 39001, 44044, 59678}, - opcodes = { - NOOP = 12, - OK = 0, - ERROR = 20, - BOOK_DONE = 11, - CALIBRE_BUSY = 18, - SET_LIBRARY_INFO = 19, - DELETE_BOOK = 13, - DISPLAY_MESSAGE = 17, - FREE_SPACE = 5, - GET_BOOK_FILE_SEGMENT = 14, - GET_BOOK_METADATA = 15, - GET_BOOK_COUNT = 6, - GET_DEVICE_INFORMATION = 3, - GET_INITIALIZATION_INFO = 9, - SEND_BOOKLISTS = 7, - SEND_BOOK = 8, - SEND_BOOK_METADATA = 16, - SET_CALIBRE_DEVICE_INFO = 1, - SET_CALIBRE_DEVICE_NAME = 2, - TOTAL_SPACE = 4, - }, calibre = nil, -- hash } function CalibreWireless:init() self.calibre = {} - -- reversed operator codes and names dictionary - self.opnames = {} - for name, code in pairs(self.opcodes) do - self.opnames[code] = name - end end -function CalibreWireless:find_calibre_server() +local function find_calibre_server() local socket = require("socket") local udp = socket.udp4() udp:setoption("broadcast", true) - udp:setsockname("*", 8134) + udp:setsockname("*", COMPANION_PORT) udp:settimeout(3) - for _, port in ipairs(self.broadcast_ports) do + for _, port in ipairs(BROADCAST_PORTS) do -- broadcast anything to calibre ports and listen to the reply local _, err = udp:sendto("hello", "255.255.255.255", port) if not err then @@ -109,58 +131,39 @@ function CalibreWireless:find_calibre_server() -- replied diagram has greet message from calibre and calibre hostname -- calibre opds port and calibre socket port we will later connect to local _, _, replied_port = dgram:match("calibre wireless device client %(on (.-)%);(%d+),(%d+)$") + udp:close() return host, replied_port end end end + udp:close() end -function CalibreWireless:checkCalibreServer(host, port) +local function check_host_port(host, port) local socket = require("socket") + -- luacheck: ignore 311 + local ok, err = socket.dns.getaddrinfo(host) + if not ok then + return false, "host not found" + end + local ip = ok[1].addr local tcp = socket.tcp() tcp:settimeout(5) - -- In case of error, the method returns nil followed by a string describing the error. In case of success, the method returns 1. - local ok, err = tcp:connect(host, port) - if ok then - tcp:close() - return true - end - return false, err + -- In case of error, the method returns nil followed by a string + -- describing the error. In case of success, the method returns 1. + ok, err = tcp:connect(ip, port) + tcp:close() + return ok, err end -- Standard JSON/control opcodes receive callback -function CalibreWireless:JSONReceiveCallback(host, port) +function CalibreWireless:JSONReceiveCallback() -- NOTE: Closure trickery because we need a reference to *this* self *inside* the callback, -- which will be called as a function from another object (namely, StreamMessageQueue). local this = self return function(t) local data = table.concat(t) this:onReceiveJSON(data) - if not this.connect_message then - this.password_check_callback = function() - local msg - if this.invalid_password then - msg = _("Invalid password") - this.invalid_password = nil - this:disconnect() - logger.warn("invalid password, disconnecting") - elseif this.disconnected_by_server then - msg = _("Disconnected by calibre") - this.disconnected_by_server = nil - logger.info("disconnected by calibre") - else - msg = T(_("Connected to calibre server at %1"), - BD.ltr(T("%1:%2", this.calibre_socket.host, this.calibre_socket.port))) - logger.info("connected successfully") - end - UIManager:show(InfoMessage:new{ - text = msg, - timeout = 2, - }) - end - this.connect_message = true - UIManager:scheduleIn(1, this.password_check_callback) - end end end @@ -173,20 +176,16 @@ function CalibreWireless:initCalibreMQ(host, port) receiveCallback = self:JSONReceiveCallback(), } self.calibre_socket:start() - self.calibre_messagequeue = UIManager:insertZMQ(self.calibre_socket) + UIManager:insertZMQ(self.calibre_socket) end - logger.info(string.format("connecting to calibre @ %s:%s", host, port)) end --- will callback initCalibreMQ if inbox is confirmed to be set -function CalibreWireless:setInboxDir(host, port) +local function set_inbox_dir(cb) local force_chooser_dir if Device:isAndroid() then force_chooser_dir = Device.home_dir end - local calibre_device = self - require("ui/downloadmgr"):new{ onConfirm = function(inbox) local driver = CalibreMetadata:getDeviceInfo(inbox, "device_name") @@ -194,13 +193,10 @@ function CalibreWireless:setInboxDir(host, port) if not driver then return end return not driver:lower():match("koreader") and not driver:lower():match("folder") end - local save_and_resume = function() + local save_and_cb = function() logger.info("set inbox directory", inbox) G_reader_settings:saveSetting("inbox_dir", inbox) - if host and port then - CalibreMetadata:init(inbox) - calibre_device:initCalibreMQ(host, port) - end + cb(inbox) end -- probably not a good idea to mix calibre drivers because -- their default settings usually don't match (lpath et al) @@ -213,74 +209,169 @@ Mixing calibre libraries is not recommended unless you know what you're doing. Do you want to continue? ]]), driver), ok_text = _("Continue"), - ok_callback = function() - save_and_resume() - end, + ok_callback = save_and_cb }) else - save_and_resume() + save_and_cb() end end, }:chooseDir(force_chooser_dir) end function CalibreWireless:connect() - if NetworkMgr:willRerunWhenConnected(function() self:connect() end) then + if self.calibre_socket ~= nil then return end - self.connect_message = false - local address_type, host, port, ok, err - if G_reader_settings:hasNot("calibre_wireless_url") then - host, port = self:find_calibre_server() - if host and port then - address_type = "discovered" - else - ok = false - err = _("Couldn't discover a calibre instance on the local network") - address_type = "unavailable" - end - else - local calibre_url = G_reader_settings:readSetting("calibre_wireless_url") - host, port = calibre_url["address"], calibre_url["port"] - address_type = "specified" + -- Ensure we're running in a coroutine. + local co = coroutine.running() + if not co then + Trapper:wrap(function() self:connect() end) + return + end + local re = function(res) coroutine.resume(co, res) end + + -- Setup inbox directory. + local inbox_dir = G_reader_settings:readSetting("inbox_dir") + if not inbox_dir then + set_inbox_dir(re) + inbox_dir = coroutine.yield() end - if host and port then - ok, err = self:checkCalibreServer(host, port) + -- Ensure network is online. + if NetworkMgr:willRerunWhenConnected(self.re) then + coroutine.yield() + if not NetworkMgr:isConnected() then + return + end + end + + local address_type, host, port, ok, err + + -- Setup server address. + local calibre_url = G_reader_settings:readSetting("calibre_wireless_url") + if calibre_url then + host, port = calibre_url["address"], calibre_url["port"] + address_type = "specified" + ok = true + else + logger.info("calibre: searching for a server") + Trapper:info(_("Searching for a calibre server… (tap to cancel)")) + ok, host, port = Trapper:dismissableRunInSubprocess(find_calibre_server) + if not ok then + -- Canceled. + return + end + if not host or not port then + Trapper:info(_("Couldn't discover a calibre instance on the local network")) + return + end + address_type = "discovered" + end + + local server_info = BD.ltr(T("%1:%2", host, port)) + + -- Yield for `timeout` seconds, return: + -- - `true` if resumed because of some server activity + -- - `false` on abort / cancelation / disconnection + -- - `nil` on timeout + local resume_in = function(timeout) + UIManager:scheduleIn(timeout, re) + local result = coroutine.yield() + UIManager:unschedule(re) + return result + end + + if ok then + -- Start connection. + logger.info(string.format("calibre: connecting to %s:%s (%s)", host, port, address_type)) + -- @translators %1: address (host:port), %2: address type (discovered, specified, unavailable) + Trapper:info(T(_("Connecting to calibre server at %1 (%2, tap to cancel)"), server_info, _(address_type))) + self.re = re + self.invalid_password = false + self.disconnected_by_server = false + if pcall(self.initCalibreMQ, self, host, port) then + CalibreMetadata:init(inbox_dir) + -- And wait for initial requests: GET_INITIALIZATION_INFO, followed by + -- GET_DEVICE_INFORMATION (or a specific DISPLAY_MESSAGE on password error). + for _, timeout in ipairs{5, 1} do + ok = resume_in(timeout) + if not ok then + break + end + end + else + ok = nil + end + if ok == false then + -- Connection was canceled by the user. + self:disconnect(true) + return + end + if self.invalid_password then + ok, err = false, "invalid password" + elseif not ok then + -- Manually open a TCP connection to get a more informative error. + ok, err = check_host_port(host, port) + if ok then + ok, err = false, "handshake timeout" + else + err = err:lower() + end + end end if not ok then - host = host or "????" - port = port or "??" - err = err or _("N/A") - logger.warn(string.format("Cannot connect to %s calibre server at %s:%s (%s)", address_type, host, port, err)) - UIManager:show(InfoMessage:new{ - text = T(_("Cannot connect to calibre server at %1 (%2)"), - BD.ltr(T("%1:%2", host, port)), err) - }) - else - local inbox_dir = G_reader_settings:readSetting("inbox_dir") - if inbox_dir then - CalibreMetadata:init(inbox_dir) - self:initCalibreMQ(host, port) - else - self:setInboxDir(host, port) - end + logger.warn("calibre: connection failed,", err) + -- @translators %1: address (host:port), %2: error + Trapper:info(T(_("Cannot connect to calibre server at %1 (%2)"), server_info, _(err))) + self:disconnect(not self.invalid_password) + return end + + Trapper:clear() + + logger.info("calibre: connected") + + -- Heartbeat monitoring… + while ok and not self.disconnected_by_server do + ok = resume_in(15) + end + + local msg + if self.disconnected_by_server then + logger.info("disconnected by calibre") + msg = _("Disconnected by calibre") + elseif ok == nil then + logger.info("calibre: no activity") + msg = _("Disconnected from calibre (no activity)") + end + if msg then + UIManager:show(InfoMessage:new{ text = msg, timeout = 2 }) + self:disconnect(not self.disconnected_by_server) + end + end -function CalibreWireless:disconnect() - logger.info("disconnecting from calibre") - self.connect_message = false - if self.calibre_socket then - self.calibre_socket:stop() - self.calibre_socket = nil +function CalibreWireless:disconnect(no_parting_noop) + if self.calibre_socket == nil then + return end - if self.calibre_messagequeue then - UIManager:removeZMQ(self.calibre_messagequeue) - self.calibre_messagequeue = nil + logger.info("calibre: disconnecting") + + self.re(false) + self.re = nil + + if not no_parting_noop then + self:sendJsonData('NOOP', {}) end + + UIManager:removeZMQ(self.calibre_socket) + self.calibre_socket:stop() + self.calibre_socket = nil + self.invalid_password = false + self.disconnected_by_server = false + CalibreMetadata:clean() -- Assume the library content was modified, as such, invalidate our Search metadata cache. @@ -302,7 +393,7 @@ function CalibreWireless:onReceiveJSON(data) -- 34[0, {"key0":value, "key1": value}] -- the JSON string has a leading length string field followed by the actual -- JSON data in which the first element is always the operator code which can - -- be looked up in the opnames dictionary + -- be looked up in the opcodes dictionary while self.buffer ~= nil do --logger.info("buffer", self.buffer) local index = self.buffer:find('%[') or 1 @@ -323,29 +414,30 @@ function CalibreWireless:onReceiveJSON(data) --logger.dbg("received json table", json) local opcode = json[1] local arg = json[2] - if self.opnames[opcode] == 'GET_INITIALIZATION_INFO' then + if opcode == OPCODES.GET_INITIALIZATION_INFO then self:getInitInfo(arg) - elseif self.opnames[opcode] == 'GET_DEVICE_INFORMATION' then + elseif opcode == OPCODES.GET_DEVICE_INFORMATION then self:getDeviceInfo(arg) - elseif self.opnames[opcode] == 'SET_CALIBRE_DEVICE_INFO' then + elseif opcode == OPCODES.SET_CALIBRE_DEVICE_INFO then self:setCalibreInfo(arg) - elseif self.opnames[opcode] == 'FREE_SPACE' then + elseif opcode == OPCODES.FREE_SPACE then self:getFreeSpace(arg) - elseif self.opnames[opcode] == 'SET_LIBRARY_INFO' then + elseif opcode == OPCODES.SET_LIBRARY_INFO then self:setLibraryInfo(arg) - elseif self.opnames[opcode] == 'GET_BOOK_COUNT' then + elseif opcode == OPCODES.GET_BOOK_COUNT then self:getBookCount(arg) - elseif self.opnames[opcode] == 'SEND_BOOK' then + elseif opcode == OPCODES.SEND_BOOK then self:sendBook(arg) - elseif self.opnames[opcode] == 'DELETE_BOOK' then + elseif opcode == OPCODES.DELETE_BOOK then self:deleteBook(arg) - elseif self.opnames[opcode] == 'GET_BOOK_FILE_SEGMENT' then + elseif opcode == OPCODES.GET_BOOK_FILE_SEGMENT then self:sendToCalibre(arg) - elseif self.opnames[opcode] == 'DISPLAY_MESSAGE' then + elseif opcode == OPCODES.DISPLAY_MESSAGE then self:serverFeedback(arg) - elseif self.opnames[opcode] == 'NOOP' then + elseif opcode == OPCODES.NOOP then self:noop(arg) end + self.re(true) else logger.warn("failed to decode json data", err) end @@ -353,7 +445,7 @@ function CalibreWireless:onReceiveJSON(data) end function CalibreWireless:sendJsonData(opname, data) - local json, err = rapidjson.encode(rapidjson.array({self.opcodes[opname], data})) + local json, err = rapidjson.encode(rapidjson.array({OPCODES[opname], data})) if json then -- length of json data should be before the real json data self.calibre_socket:send(tostring(#json)..json) @@ -499,7 +591,6 @@ function CalibreWireless:noop(arg) if arg.ejecting then self:sendJsonData('OK', {}) self.disconnected_by_server = true - self:disconnect() return end -- calibre announces the count of books that need more metadata @@ -585,13 +676,12 @@ function CalibreWireless:sendBook(arg) calibre_device.buffer = data:sub(#to_write_data + 1) or "" --logger.info("device buffer", calibre_device.buffer) if calibre_device.buffer ~= "" then - UIManager:scheduleIn(0.1, function() - -- since data is already copied to buffer - -- onReceiveJSON parameter should be nil - calibre_device:onReceiveJSON() - end) + -- since data is already copied to buffer + -- onReceiveJSON parameter should be nil + calibre_device:onReceiveJSON() end end + self.re(true) end self:sendJsonData('OK', {}) -- end of the batch