From 7ad0cfa134a88c145fda8ef830eba7974552c93c Mon Sep 17 00:00:00 2001 From: JLP Date: Tue, 14 Apr 2026 07:21:46 -0400 Subject: [PATCH 1/3] LVGL Lua --- lua/mLRS-LVGL.lua | 1495 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1495 insertions(+) create mode 100644 lua/mLRS-LVGL.lua diff --git a/lua/mLRS-LVGL.lua b/lua/mLRS-LVGL.lua new file mode 100644 index 000000000..df6bf462c --- /dev/null +++ b/lua/mLRS-LVGL.lua @@ -0,0 +1,1495 @@ +-- TNS|mLRS Configurator LVGL|TNE +---------------------------------------------------------------------- +-- Copyright (c) MLRS project +-- GPL3 +-- https://www.gnu.org/licenses/gpl-3.0.de.html +-- OlliW @ www.olliw.eu +---------------------------------------------------------------------- +-- Lua TOOLS script, LVGL version for EdgeTX 2.11.5+ +---------------------------------------------------------------------- +-- copy script to SCRIPTS\TOOLS folder on EdgeTx SD card +-- works with mLRS v1.3.03 and later + +local VERSION = { + script = '2026-04-13 LVGL', + required_tx_version_int = 10303, -- 'v1.3.03' + required_rx_version_int = 10303, -- 'v1.3.03' +} + + +-- experimental +local paramLoadDeadTime_10ms = 300 + + +---------------------------------------------------------------------- +-- LVGL layout constants +---------------------------------------------------------------------- + +local IS_NARROW = LCD_W < 400 +local LABEL_PCT = IS_NARROW and 42 or 50 +local CTRL_PCT = 100 - LABEL_PCT + + +-- ============================================================================ +-- App Module: Application state and coordinators +-- ============================================================================ + +local App = { + page = "main", -- "main", "edit_tx", "edit_rx", "tools" + shouldExit = false, + moduleChecked = false, + isFirstParamDownload = true, + firstParamDownloadTmo_10ms = 0, + baudRateWarningShown = false, +} + + +-- ============================================================================ +-- Protocol Module: mBridge/CRSF communication +-- ============================================================================ + +local Protocol = { + isConnected = nil, + cmdPush = nil, + cmdPop = nil, + + connected = false, + connected_has_changed = false, + has_connected = false, + has_disconnected = false, + + paramloop_t_last = 0, + DEVICE_ITEM_TX = nil, + DEVICE_ITEM_RX = nil, + DEVICE_INFO = nil, + DEVICE_PARAM_LIST = nil, + DEVICE_PARAM_LIST_expected_index = 0, + DEVICE_PARAM_LIST_current_index = -1, + DEVICE_PARAM_LIST_max_index = 0, + DEVICE_PARAM_LIST_errors = 0, + DEVICE_PARAM_LIST_complete = false, + DEVICE_DOWNLOAD_is_running = true, + DEVICE_SAVE_t_last = 0, +} + +local MBRIDGE_COMMANDPACKET_STX = 0xA0 + +local MBRIDGE_CMD = { + TX_LINK_STATS = 2, + REQUEST_INFO = 3, + DEVICE_ITEM_TX = 4, + DEVICE_ITEM_RX = 5, + PARAM_REQUEST_LIST = 6, + PARAM_ITEM = 7, + PARAM_ITEM2 = 8, + PARAM_ITEM3_4 = 9, + REQUEST_CMD = 10, + INFO = 11, + PARAM_SET = 12, + PARAM_STORE = 13, + BIND_START = 14, + BIND_STOP = 15, + MODELID_SET = 16, + SYSTEM_BOOTLOADER = 17, + FLASH_ESP = 18, +} + +local MBRIDGE_CMD_LEN = { + [MBRIDGE_CMD.TX_LINK_STATS] = 22, + [MBRIDGE_CMD.DEVICE_ITEM_TX] = 24, + [MBRIDGE_CMD.DEVICE_ITEM_RX] = 24, + [MBRIDGE_CMD.PARAM_ITEM] = 24, + [MBRIDGE_CMD.PARAM_ITEM2] = 24, + [MBRIDGE_CMD.PARAM_ITEM3_4] = 24, + [MBRIDGE_CMD.REQUEST_CMD] = 18, + [MBRIDGE_CMD.INFO] = 24, + [MBRIDGE_CMD.PARAM_SET] = 7, + [MBRIDGE_CMD.MODELID_SET] = 3, +} + +local MBRIDGE_PARAM_TYPE = { + UINT8 = 0, + INT8 = 1, + UINT16 = 2, + INT16 = 3, + LIST = 4, + STR6 = 5, +} + +local bindphrase_chars = "abcdefghijklmnopqrstuvwxyz0123456789_#-." + +local diversity_list = { + [0] = "enabled", + [1] = "antenna1", + [2] = "antenna2", + [3] = "r:en. t:ant1", + [4] = "r:en. t:ant2", +} + +local freq_band_list = { + [0] = "2.4 GHz", + [1] = "915 MHz FCC", + [2] = "868 MHz", + [3] = "433 MHz", + [4] = "70 cm HAM", + [5] = "866 MHz IN", +} + + +---------------------------------------------------------------------- +-- Protocol helpers +---------------------------------------------------------------------- + +local function mbridgeCmdLen(cmd) + return MBRIDGE_CMD_LEN[cmd] or 0 +end + +local function mb_to_string(payload, pos, len) + local str = "" + for i = 0, len-1 do + if payload[pos+i] == 0 then break end + str = str .. string.char(payload[pos+i]) + end + return str +end + +local function mb_to_u8(payload, pos) + return payload[pos] +end + +local function mb_to_i8(payload, pos) + local v = payload[pos+0] + if v >= 128 then v = v - 256 end + return v +end + +local function mb_to_u16(payload, pos) + return payload[pos+0] + payload[pos+1]*256 +end + +local function mb_to_i16(payload, pos) + local v = payload[pos+0] + payload[pos+1]*256 + if v >= 32768 then v = v - 65536 end + return v +end + +local function mb_to_value(payload, pos, typ) + if typ == MBRIDGE_PARAM_TYPE.UINT8 then + return mb_to_u8(payload, pos) + elseif typ == MBRIDGE_PARAM_TYPE.INT8 then + return mb_to_i8(payload, pos) + elseif typ == MBRIDGE_PARAM_TYPE.UINT16 then + return mb_to_u16(payload, pos) + elseif typ == MBRIDGE_PARAM_TYPE.INT16 then + return mb_to_i16(payload, pos) + elseif typ == MBRIDGE_PARAM_TYPE.LIST then + return payload[pos+0] + end + return 0 +end + +local function mb_to_value_or_str6(payload, pos, typ) + if typ == MBRIDGE_PARAM_TYPE.STR6 then + return mb_to_string(payload, pos, 6) + else + return mb_to_value(payload, pos, typ) + end +end + +local function mb_to_options(payload, pos, len) + local str = "" + for i = 0, len-1 do + if payload[pos+i] == 0 then break end + str = str .. string.char(payload[pos+i]) + end + str = str .. "," + local opt = {} + for s in string.gmatch(str, "([^,]+)") do + table.insert(opt, s) + end + return opt +end + +local function mb_to_version_int(u16) + local major = bit32.rshift(bit32.band(u16, 0xF000), 12) + local minor = bit32.rshift(bit32.band(u16, 0x0FC0), 6) + local patch = bit32.band(u16, 0x003F) + return major * 10000 + minor * 100 + patch +end + +local function mb_to_version_string(u16) + local major = bit32.rshift(bit32.band(u16, 0xF000), 12) + local minor = bit32.rshift(bit32.band(u16, 0x0FC0), 6) + local patch = bit32.band(u16, 0x003F) + return string.format("v%d.%d.%02d", major, minor, patch) +end + +local function mb_to_u8_bits(payload, pos, bitpos, bitmask) + local v = payload[pos] + v = bit32.rshift(v, bitpos) + v = bit32.band(v, bitmask) + return v +end + +local function mb_allowed_mask_editable(allowed_mask) + if allowed_mask == 0 then return false end + if allowed_mask == 1 then return false end + if allowed_mask == 2 then return false end + if allowed_mask == 4 then return false end + if allowed_mask == 8 then return false end + if allowed_mask == 16 then return false end + if allowed_mask == 32 then return false end + if allowed_mask == 64 then return false end + if allowed_mask == 128 then return false end + if allowed_mask == 256 then return false end + return true +end + +local function getExceptNoFromChar(c) + if (c >= 'a' and c <= 'z') then return (string.byte(c) - string.byte('a')) % 5 end + if (c >= '0' and c <= '9') then return (string.byte(c) - string.byte('0')) % 5 end + if (c == '_') then return 1 end + if (c == '#') then return 2 end + if (c == '-') then return 3 end + if (c == '.') then return 4 end + return 0 +end + +local function getExceptStrFromChar(c) + local n = getExceptNoFromChar(c) + if (n == 1) then return "/e1" end + if (n == 2) then return "/e6" end + if (n == 3) then return "/e11" end + if (n == 4) then return "/e13" end + return "/--" +end + + +---------------------------------------------------------------------- +-- Protocol: Bridge setup +---------------------------------------------------------------------- + +local function crsfIsConnected() + if getRSSI() ~= 0 then return true end + return false +end + +local function crsfCmdPush(cmd, payload) + local data = { 79, 87, cmd + MBRIDGE_COMMANDPACKET_STX } + for i = 1, mbridgeCmdLen(cmd) do data[#data + 1] = 0 end + for i = 1, #payload do data[3 + i] = payload[i] end + return crossfireTelemetryPush(129, data) +end + +local function crsfCmdPop() + local cmd, data = crossfireTelemetryPop() + if cmd == nil then return nil end + if data == nil or data[1] == nil then return nil end + local command = data[1] - MBRIDGE_COMMANDPACKET_STX + local res = { + cmd = command, + len = mbridgeCmdLen(command), + payload = {} + } + for i = 2, #data do res.payload[i-2] = data[i] end + return res +end + +local function mbridgeIsConnected() + local LStats = mbridge.getLinkStats() + if LStats.LQ > 0 then return true end + return false +end + +function Protocol.setupBridge() + if mbridge == nil or not mbridge.enabled() then + Protocol.isConnected = crsfIsConnected + Protocol.cmdPush = crsfCmdPush + Protocol.cmdPop = crsfCmdPop + else + Protocol.isConnected = mbridgeIsConnected + Protocol.cmdPush = mbridge.cmdPush + Protocol.cmdPop = mbridge.cmdPop + end +end + + +---------------------------------------------------------------------- +-- Protocol: Connection tracking +---------------------------------------------------------------------- + +function Protocol.doConnected() + local is_connected = Protocol.isConnected() + + Protocol.connected_has_changed = false + if is_connected ~= Protocol.connected then Protocol.connected_has_changed = true end + + Protocol.has_connected = false + if Protocol.connected_has_changed and is_connected then Protocol.has_connected = true end + + Protocol.has_disconnected = false + if Protocol.connected_has_changed and not is_connected then Protocol.has_disconnected = true end + + Protocol.connected = is_connected +end + + +---------------------------------------------------------------------- +-- Protocol: Parameter management +---------------------------------------------------------------------- + +function Protocol.clearParams() + Protocol.DEVICE_ITEM_TX = nil + Protocol.DEVICE_ITEM_RX = nil + Protocol.DEVICE_INFO = nil + Protocol.DEVICE_PARAM_LIST = nil + Protocol.DEVICE_PARAM_LIST_expected_index = 0 + Protocol.DEVICE_PARAM_LIST_current_index = -1 + Protocol.DEVICE_PARAM_LIST_errors = 0 + Protocol.DEVICE_PARAM_LIST_complete = false + Protocol.DEVICE_DOWNLOAD_is_running = true +end + +local function paramsError() + Protocol.DEVICE_PARAM_LIST_errors = Protocol.DEVICE_PARAM_LIST_errors + 1 +end + +local function updateDeviceParamCounts(f) + if f <= 1 then + if Protocol.DEVICE_INFO.param_num > 0 then + Protocol.DEVICE_PARAM_LIST_max_index = Protocol.DEVICE_INFO.param_num - 1 + end + else + if Protocol.DEVICE_INFO.param_num == 0 then + Protocol.DEVICE_PARAM_LIST_max_index = #Protocol.DEVICE_PARAM_LIST + end + end +end + + +---------------------------------------------------------------------- +-- Protocol: Parameter loop (request-response state machine) +---------------------------------------------------------------------- +-- Forward declaration for Dialogs (defined later) +local Dialogs = {} + +function Protocol.doParamLoop() + local t_10ms = getTime() + if t_10ms - Protocol.paramloop_t_last > 33 then + Protocol.paramloop_t_last = t_10ms + if t_10ms < Protocol.DEVICE_SAVE_t_last + paramLoadDeadTime_10ms then + -- skip, we don't send a cmd if the last Save was recent + elseif Protocol.DEVICE_ITEM_TX == nil then + Protocol.cmdPush(MBRIDGE_CMD.REQUEST_INFO, {}) + Protocol.DEVICE_PARAM_LIST_expected_index = 0 + Protocol.DEVICE_PARAM_LIST_current_index = -1 + Protocol.DEVICE_PARAM_LIST_errors = 0 + Protocol.DEVICE_PARAM_LIST_complete = false + elseif Protocol.DEVICE_PARAM_LIST == nil then + if Protocol.DEVICE_INFO ~= nil then + Protocol.DEVICE_PARAM_LIST = {} + Protocol.cmdPush(MBRIDGE_CMD.REQUEST_CMD, {MBRIDGE_CMD.PARAM_ITEM, Protocol.DEVICE_PARAM_LIST_expected_index}) + end + end + end + + -- handle received commands + for ijk = 1, 24 do + local cmd = Protocol.cmdPop() + if cmd == nil then break end + if cmd.cmd == MBRIDGE_CMD.DEVICE_ITEM_TX then + Protocol.DEVICE_ITEM_TX = cmd + Protocol.DEVICE_ITEM_TX.version_u16 = mb_to_u16(cmd.payload, 0) + Protocol.DEVICE_ITEM_TX.setuplayout_u16 = mb_to_u16(cmd.payload, 2) + Protocol.DEVICE_ITEM_TX.name = mb_to_string(cmd.payload, 4, 20) + Protocol.DEVICE_ITEM_TX.version_int = mb_to_version_int(Protocol.DEVICE_ITEM_TX.version_u16) + Protocol.DEVICE_ITEM_TX.version_str = mb_to_version_string(Protocol.DEVICE_ITEM_TX.version_u16) + Protocol.DEVICE_ITEM_TX.setuplayout_int = mb_to_version_int(Protocol.DEVICE_ITEM_TX.setuplayout_u16) + elseif cmd.cmd == MBRIDGE_CMD.DEVICE_ITEM_RX then + Protocol.DEVICE_ITEM_RX = cmd + Protocol.DEVICE_ITEM_RX.version_u16 = mb_to_u16(cmd.payload, 0) + Protocol.DEVICE_ITEM_RX.setuplayout_u16 = mb_to_u16(cmd.payload, 2) + Protocol.DEVICE_ITEM_RX.name = mb_to_string(cmd.payload, 4, 20) + Protocol.DEVICE_ITEM_RX.version_int = mb_to_version_int(Protocol.DEVICE_ITEM_RX.version_u16) + Protocol.DEVICE_ITEM_RX.version_str = mb_to_version_string(Protocol.DEVICE_ITEM_RX.version_u16) + Protocol.DEVICE_ITEM_RX.setuplayout_int = mb_to_version_int(Protocol.DEVICE_ITEM_RX.setuplayout_u16) + elseif cmd.cmd == MBRIDGE_CMD.INFO then + Protocol.DEVICE_INFO = cmd + Protocol.DEVICE_INFO.receiver_sensitivity = mb_to_i16(cmd.payload, 0) + Protocol.DEVICE_INFO.has_status = mb_to_u8_bits(cmd.payload, 2, 0, 0x01) + Protocol.DEVICE_INFO.binding = mb_to_u8_bits(cmd.payload, 2, 1, 0x01) + Protocol.DEVICE_INFO.tx_power_dbm = mb_to_i8(cmd.payload, 3) + Protocol.DEVICE_INFO.rx_power_dbm = mb_to_i8(cmd.payload, 4) + Protocol.DEVICE_INFO.rx_available = mb_to_u8_bits(cmd.payload, 5, 0, 0x1) + Protocol.DEVICE_INFO.tx_config_id = mb_to_u8(cmd.payload, 6) + Protocol.DEVICE_INFO.tx_diversity = mb_to_u8_bits(cmd.payload, 7, 0, 0x0F) + Protocol.DEVICE_INFO.rx_diversity = mb_to_u8_bits(cmd.payload, 7, 4, 0x0F) + Protocol.DEVICE_INFO.param_num = mb_to_u8(cmd.payload, 8) + updateDeviceParamCounts(1) + elseif cmd.cmd == MBRIDGE_CMD.PARAM_ITEM then + local index = cmd.payload[0] + if index ~= Protocol.DEVICE_PARAM_LIST_expected_index and index ~= 255 then + paramsError() + end + Protocol.DEVICE_PARAM_LIST_current_index = index + Protocol.DEVICE_PARAM_LIST_expected_index = index + 1 + if Protocol.DEVICE_PARAM_LIST == nil then + paramsError() + elseif index < 128 then + Protocol.DEVICE_PARAM_LIST[index] = cmd + Protocol.DEVICE_PARAM_LIST[index].typ = mb_to_u8(cmd.payload, 1) + Protocol.DEVICE_PARAM_LIST[index].name = mb_to_string(cmd.payload, 2, 16) + Protocol.DEVICE_PARAM_LIST[index].value = mb_to_value_or_str6(cmd.payload, 18, Protocol.DEVICE_PARAM_LIST[index].typ) + Protocol.DEVICE_PARAM_LIST[index].min = 0 + Protocol.DEVICE_PARAM_LIST[index].max = 0 + Protocol.DEVICE_PARAM_LIST[index].unit = "" + Protocol.DEVICE_PARAM_LIST[index].options = {} + Protocol.DEVICE_PARAM_LIST[index].allowed_mask = 65536 + Protocol.DEVICE_PARAM_LIST[index].editable = true + elseif index == 255 then -- EOL + if Protocol.DEVICE_PARAM_LIST_errors == 0 then + updateDeviceParamCounts(2) + Protocol.DEVICE_PARAM_LIST_complete = true + else + Protocol.DEVICE_PARAM_LIST_complete = false + Dialogs.showPopup("Param Upload Errors ("..tostring(Protocol.DEVICE_PARAM_LIST_errors)..")!\nTry Reload", 200) + end + Protocol.DEVICE_DOWNLOAD_is_running = false + else + paramsError() + end + elseif cmd.cmd == MBRIDGE_CMD.PARAM_ITEM2 then + local index = cmd.payload[0] + if index ~= Protocol.DEVICE_PARAM_LIST_current_index then + paramsError() + elseif Protocol.DEVICE_PARAM_LIST == nil or Protocol.DEVICE_PARAM_LIST[index] == nil then + paramsError() + else + local item3_needed = false + if Protocol.DEVICE_PARAM_LIST[index].typ < MBRIDGE_PARAM_TYPE.LIST then + Protocol.DEVICE_PARAM_LIST[index].min = mb_to_value(cmd.payload, 1, Protocol.DEVICE_PARAM_LIST[index].typ) + Protocol.DEVICE_PARAM_LIST[index].max = mb_to_value(cmd.payload, 3, Protocol.DEVICE_PARAM_LIST[index].typ) + Protocol.DEVICE_PARAM_LIST[index].unit = mb_to_string(cmd.payload, 7, 6) + elseif Protocol.DEVICE_PARAM_LIST[index].typ == MBRIDGE_PARAM_TYPE.LIST then + Protocol.DEVICE_PARAM_LIST[index].allowed_mask = mb_to_u16(cmd.payload, 1) + Protocol.DEVICE_PARAM_LIST[index].options = mb_to_options(cmd.payload, 3, 21) + Protocol.DEVICE_PARAM_LIST[index].item2payload = cmd.payload + Protocol.DEVICE_PARAM_LIST[index].min = 0 + Protocol.DEVICE_PARAM_LIST[index].max = #Protocol.DEVICE_PARAM_LIST[index].options - 1 + Protocol.DEVICE_PARAM_LIST[index].editable = mb_allowed_mask_editable(Protocol.DEVICE_PARAM_LIST[index].allowed_mask) + local s = mb_to_string(cmd.payload, 3, 21) + if string.len(s) == 21 then item3_needed = true end + elseif Protocol.DEVICE_PARAM_LIST[index].typ == MBRIDGE_PARAM_TYPE.STR6 then + -- nothing to do + else + paramsError() + end + if not item3_needed then + Protocol.cmdPush(MBRIDGE_CMD.REQUEST_CMD, {MBRIDGE_CMD.PARAM_ITEM, Protocol.DEVICE_PARAM_LIST_expected_index}) + end + end + elseif cmd.cmd == MBRIDGE_CMD.PARAM_ITEM3_4 then + local index = cmd.payload[0] + local is_item4 = false + if (index >= 128) then + index = index - 128 + is_item4 = true + end + if index ~= Protocol.DEVICE_PARAM_LIST_current_index then + paramsError() + elseif Protocol.DEVICE_PARAM_LIST == nil or Protocol.DEVICE_PARAM_LIST[index] == nil then + paramsError() + elseif Protocol.DEVICE_PARAM_LIST[index].typ ~= MBRIDGE_PARAM_TYPE.LIST then + paramsError() + elseif Protocol.DEVICE_PARAM_LIST[index].item2payload == nil then + paramsError() + elseif is_item4 and Protocol.DEVICE_PARAM_LIST[index].item3payload == nil then + paramsError() + else + local s = Protocol.DEVICE_PARAM_LIST[index].item2payload + local item4_needed = false + if not is_item4 then + Protocol.DEVICE_PARAM_LIST[index].item3payload = cmd.payload + for i = 1, 23 do s[23+i] = cmd.payload[i] end + Protocol.DEVICE_PARAM_LIST[index].options = mb_to_options(s, 3, 21+23) + local opts = mb_to_string(cmd.payload, 1, 23) + if string.len(opts) == 23 then item4_needed = true end + else + local s3 = Protocol.DEVICE_PARAM_LIST[index].item3payload + for i = 1, 23 do s[23+i] = s3[i]; s[23+23+i] = cmd.payload[i] end + Protocol.DEVICE_PARAM_LIST[index].options = mb_to_options(s, 3, 21+23+23) + end + Protocol.DEVICE_PARAM_LIST[index].max = #Protocol.DEVICE_PARAM_LIST[index].options - 1 + s = nil + if not item4_needed then + Protocol.cmdPush(MBRIDGE_CMD.REQUEST_CMD, {MBRIDGE_CMD.PARAM_ITEM, Protocol.DEVICE_PARAM_LIST_expected_index}) + end + end + end + cmd = nil + end +end + + +---------------------------------------------------------------------- +-- Protocol: Send commands +---------------------------------------------------------------------- + +function Protocol.sendParamSet(idx) + if not Protocol.DEVICE_PARAM_LIST_complete then return end + local p = Protocol.DEVICE_PARAM_LIST[idx] + if p.typ < MBRIDGE_PARAM_TYPE.LIST then + Protocol.cmdPush(MBRIDGE_CMD.PARAM_SET, {idx, p.value}) + elseif p.typ == MBRIDGE_PARAM_TYPE.LIST then + Protocol.cmdPush(MBRIDGE_CMD.PARAM_SET, {idx, p.value}) + elseif p.typ == MBRIDGE_PARAM_TYPE.STR6 then + local cmd = {idx} + for i = 1, 6 do + cmd[i+1] = string.byte(string.sub(p.value, i, i)) + end + Protocol.cmdPush(MBRIDGE_CMD.PARAM_SET, cmd) + end +end + +function Protocol.sendParamStore() + if not Protocol.DEVICE_PARAM_LIST_complete then return end + Protocol.cmdPush(MBRIDGE_CMD.PARAM_STORE, {}) + Protocol.DEVICE_SAVE_t_last = getTime() + Dialogs.showPopup("Save Parameters", 250) +end + +function Protocol.sendBind() + if Protocol.DEVICE_DOWNLOAD_is_running then return end + Protocol.cmdPush(MBRIDGE_CMD.BIND_START, {}) + Dialogs.showBlocked("Binding") +end + +function Protocol.sendBoot() + if Protocol.DEVICE_DOWNLOAD_is_running then return end + Protocol.cmdPush(MBRIDGE_CMD.SYSTEM_BOOTLOADER, {}) + Dialogs.showBlocked("In System Bootloader") +end + +function Protocol.sendFlashEsp() + if Protocol.DEVICE_DOWNLOAD_is_running then return end + Protocol.cmdPush(MBRIDGE_CMD.FLASH_ESP, {}) + Dialogs.showBlocked("In Flash ESP") +end + +function Protocol.checkBind() + if Protocol.DEVICE_DOWNLOAD_is_running then return end + if Protocol.DEVICE_INFO ~= nil and Protocol.DEVICE_INFO.has_status == 1 and Protocol.DEVICE_INFO.binding == 1 then + Dialogs.showBlocked("Binding") + end +end + + +---------------------------------------------------------------------- +-- Protocol: Parameter value editing +---------------------------------------------------------------------- + +function Protocol.paramValueInc(idx) + if not Protocol.DEVICE_PARAM_LIST_complete then return end + local p = Protocol.DEVICE_PARAM_LIST[idx] + if p.typ < MBRIDGE_PARAM_TYPE.LIST then + p.value = p.value + 1 + elseif p.typ == MBRIDGE_PARAM_TYPE.LIST then + local value = p.value + while value <= p.max do + value = value + 1 + local m = bit32.lshift(1, value) + if bit32.btest(m, p.allowed_mask) then p.value = value; break end + end + end + if p.value > p.max then p.value = p.max end + Protocol.DEVICE_PARAM_LIST[idx].value = p.value +end + +function Protocol.paramValueDec(idx) + if not Protocol.DEVICE_PARAM_LIST_complete then return end + local p = Protocol.DEVICE_PARAM_LIST[idx] + if p.typ < MBRIDGE_PARAM_TYPE.LIST then + p.value = p.value - 1 + elseif p.typ == MBRIDGE_PARAM_TYPE.LIST then + local value = p.value + while value >= p.min do + value = value - 1 + local m = bit32.lshift(1, value) + if bit32.btest(m, p.allowed_mask) then p.value = value; break end + end + end + if p.value < p.min then p.value = p.min end + Protocol.DEVICE_PARAM_LIST[idx].value = p.value +end + +function Protocol.paramFocusable(idx) + if not Protocol.DEVICE_PARAM_LIST_complete then return false end + local p = Protocol.DEVICE_PARAM_LIST[idx] + if p == nil then return false end + if p.editable == nil then return false end + return p.editable +end + + +-- ============================================================================ +-- Dialogs Module +-- ============================================================================ + +Dialogs.popup = nil -- {dialog, tend_10ms} + +function Dialogs.showPopup(text, tmo_10ms) + if Dialogs.popup and Dialogs.popup.tend_10ms and Dialogs.popup.tend_10ms < 0 then + return -- blocked popup on display, don't override + end + -- Close existing popup if any + if Dialogs.popup and Dialogs.popup.dialog then + Dialogs.popup.dialog:close() + end + local dg = lvgl.dialog({ + title = "mLRS", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + }) + -- Handle multi-line text + local i = string.find(text, "\n") + if i == nil then + dg:build({ + {type="label", text=text, align=CENTER, font=MIDSIZE}, + }) + else + local t1 = string.sub(text, 1, i-1) + local t2 = string.sub(text, i+1) + dg:build({ + {type="label", text=t1, align=CENTER, font=MIDSIZE}, + {type="label", text=t2, align=CENTER, font=MIDSIZE}, + }) + end + Dialogs.popup = {dialog = dg, tend_10ms = getTime() + tmo_10ms} +end + +function Dialogs.showBlocked(text) + -- Close existing popup if any + if Dialogs.popup and Dialogs.popup.dialog then + Dialogs.popup.dialog:close() + end + local dg = lvgl.dialog({ + title = "mLRS", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + }) + dg:build({ + {type="label", text=text, align=CENTER, font=MIDSIZE}, + }) + Dialogs.popup = {dialog = dg, tend_10ms = -1} +end + +function Dialogs.clearPopup() + if Dialogs.popup and Dialogs.popup.dialog then + Dialogs.popup.dialog:close() + end + Dialogs.popup = nil +end + +function Dialogs.clearIfBlocked() + if Dialogs.popup and Dialogs.popup.tend_10ms and Dialogs.popup.tend_10ms < 0 then + Dialogs.clearPopup() + end +end + +function Dialogs.update() + if Dialogs.popup == nil then return end + if Dialogs.popup.tend_10ms > 0 then + local t_10ms = getTime() + if t_10ms > Dialogs.popup.tend_10ms then + Dialogs.clearPopup() + end + end +end + +function Dialogs.showVersionWarning(text) + lvgl.message({ + title = "Version Warning", + message = text, + }) +end + + +-- ============================================================================ +-- UI Module: LVGL page/widget building +-- ============================================================================ + +local UI = { + currentPage = nil, + uiBuilt = false, + paramsWereComplete = false, +} + +function UI.invalidate() + UI.uiBuilt = false +end + + +---------------------------------------------------------------------- +-- UI: Subtitle +---------------------------------------------------------------------- + +function UI.getSubtitle() + if Protocol.DEVICE_DOWNLOAD_is_running then + local idx = Protocol.DEVICE_PARAM_LIST_current_index + if idx < 0 then idx = 0 end + local s = "Loading params... (" .. tostring(idx) + if Protocol.DEVICE_PARAM_LIST_max_index > 0 then + s = s .. "/" .. tostring(Protocol.DEVICE_PARAM_LIST_max_index) + end + return s .. ")" + end + return "" +end + +function UI.getMainSubtitle() + local s = UI.getSubtitle() + if s ~= "" then return s end + return VERSION.script +end + + +---------------------------------------------------------------------- +-- UI: Helpers +---------------------------------------------------------------------- + +-- Build filtered values + index mappings from allowed_mask +function UI.buildFilteredValues(param, displayList) + local filteredValues = {} + local origToFiltered = {} + local filteredToOrig = {} + for i, opt in ipairs(param.options) do + local idx0 = i - 1 + if bit32.btest(bit32.lshift(1, idx0), param.allowed_mask) then + local name = (displayList and displayList[idx0]) or opt + filteredValues[#filteredValues + 1] = name + origToFiltered[idx0] = #filteredValues + filteredToOrig[#filteredValues] = idx0 + end + end + return filteredValues, origToFiltered, filteredToOrig +end + + +---------------------------------------------------------------------- +-- UI: Widget creators +---------------------------------------------------------------------- + +function UI.createInfoRowPair(pg, label1, valueFn1, label2, valueFn2) + pg:build({ + {type="rectangle", w=lvgl.PERCENT_SIZE+100, thickness=0, flexFlow=lvgl.FLOW_ROW, flexPad=0, children={ + {type="rectangle", w=lvgl.PERCENT_SIZE+50, thickness=0, flexFlow=lvgl.FLOW_ROW, flexPad=0, children={ + {type="rectangle", w=lvgl.PERCENT_SIZE+LABEL_PCT, h=lvgl.UI_ELEMENT_HEIGHT, thickness=0, children={ + {type="label", y=lvgl.PAD_SMALL, text=label1, color=COLOR_THEME_PRIMARY1}, + }}, + {type="rectangle", w=lvgl.PERCENT_SIZE+CTRL_PCT, h=lvgl.UI_ELEMENT_HEIGHT, thickness=0, children={ + {type="label", y=lvgl.PAD_SMALL, text=valueFn1}, + }}, + }}, + {type="rectangle", w=lvgl.PERCENT_SIZE+50, thickness=0, flexFlow=lvgl.FLOW_ROW, flexPad=0, children={ + {type="rectangle", w=lvgl.PERCENT_SIZE+LABEL_PCT, h=lvgl.UI_ELEMENT_HEIGHT, thickness=0, children={ + {type="label", y=lvgl.PAD_SMALL, text=label2, color=COLOR_THEME_PRIMARY1}, + }}, + {type="rectangle", w=lvgl.PERCENT_SIZE+CTRL_PCT, h=lvgl.UI_ELEMENT_HEIGHT, thickness=0, children={ + {type="label", y=lvgl.PAD_SMALL, text=valueFn2}, + }}, + }}, + }}, + }) +end + +function UI.createInfoRow(pg, label, valueFn) + pg:build({ + {type="rectangle", w=lvgl.PERCENT_SIZE+100, thickness=0, flexFlow=lvgl.FLOW_ROW, flexPad=0, children={ + {type="rectangle", w=lvgl.PERCENT_SIZE+LABEL_PCT, h=lvgl.UI_ELEMENT_HEIGHT, thickness=0, children={ + {type="label", y=lvgl.PAD_SMALL, text=label, color=COLOR_THEME_PRIMARY1}, + }}, + {type="rectangle", w=lvgl.PERCENT_SIZE+CTRL_PCT, h=lvgl.UI_ELEMENT_HEIGHT, thickness=0, children={ + {type="label", y=lvgl.PAD_SMALL, text=valueFn}, + }}, + }}, + }) +end + +function UI.createNumberRow(pg, param, pidx) + local displayFn = function(val) + if param.unit ~= "" then + return tostring(val) .. " " .. param.unit + end + return tostring(val) + end + + pg:build({ + {type="rectangle", w=lvgl.PERCENT_SIZE+100, thickness=0, flexFlow=lvgl.FLOW_ROW, flexPad=0, children={ + {type="rectangle", w=lvgl.PERCENT_SIZE+LABEL_PCT, h=lvgl.UI_ELEMENT_HEIGHT, thickness=0, children={ + {type="label", y=lvgl.PAD_SMALL, text=string.sub(param.name, 4), color=COLOR_THEME_PRIMARY1}, + }}, + {type="rectangle", w=lvgl.PERCENT_SIZE+CTRL_PCT, thickness=0, flexFlow=lvgl.FLOW_ROW, align=LEFT, children={ + {type="numberEdit", min=param.min, max=param.max, + get=function() return param.value or 0 end, + set=function(val) + param.value = val + end, + edited=function(val) + param.value = val + Protocol.sendParamSet(pidx) + end, + display=displayFn, + active=function() return param.editable end}, + }}, + }}, + }) +end + +function UI.createFilteredChoiceRow(pg, label, param, pidx) + local row = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0, + }) + + row:rectangle({ + w = lvgl.PERCENT_SIZE + LABEL_PCT, + h = lvgl.UI_ELEMENT_HEIGHT, + thickness = 0, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + text = label, + color = COLOR_THEME_PRIMARY1, + } + } + }) + + local ctrlRect = row:rectangle({ + w = lvgl.PERCENT_SIZE + CTRL_PCT, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + align = LEFT + VCENTER, + }) + + -- Build filtered values list using allowed_mask + local filteredValues = {} + local origToFiltered = {} + local filteredToOrig = {} + for i, opt in ipairs(param.options) do + local idx0 = i - 1 + if bit32.btest(bit32.lshift(1, idx0), param.allowed_mask) then + filteredValues[#filteredValues + 1] = opt + origToFiltered[idx0] = #filteredValues + filteredToOrig[#filteredValues] = idx0 + end + end + + ctrlRect:choice({ + values = filteredValues, + get = function() return origToFiltered[param.value] or 1 end, + set = function(val) + param.value = filteredToOrig[val] or 0 + Protocol.sendParamSet(pidx) + end, + active = function() return param.editable end, + }) +end + +function UI.createFilteredChoiceRowWithDisplay(pg, label, param, pidx, displayList) + local row = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + flexPad = 0, + }) + + row:rectangle({ + w = lvgl.PERCENT_SIZE + LABEL_PCT, + h = lvgl.UI_ELEMENT_HEIGHT, + thickness = 0, + children = { + { + type = lvgl.LABEL, + y = lvgl.PAD_SMALL, + text = label, + color = COLOR_THEME_PRIMARY1, + } + } + }) + + local ctrlRect = row:rectangle({ + w = lvgl.PERCENT_SIZE + CTRL_PCT, + thickness = 0, + flexFlow = lvgl.FLOW_ROW, + align = LEFT + VCENTER, + }) + + local filteredValues = {} + local origToFiltered = {} + local filteredToOrig = {} + for i, opt in ipairs(param.options) do + local idx0 = i - 1 + if bit32.btest(bit32.lshift(1, idx0), param.allowed_mask) then + local displayName = displayList[idx0] or opt + filteredValues[#filteredValues + 1] = displayName + origToFiltered[idx0] = #filteredValues + filteredToOrig[#filteredValues] = idx0 + end + end + + ctrlRect:choice({ + values = filteredValues, + get = function() return origToFiltered[param.value] or 1 end, + set = function(val) + param.value = filteredToOrig[val] or 0 + Protocol.sendParamSet(pidx) + end, + active = function() return param.editable end, + }) +end + +---------------------------------------------------------------------- +-- UI: Main page helpers (grid-aligned label:value pairs) +---------------------------------------------------------------------- + +local ML = 40 -- label width % within a 50% half +local MV = 60 -- value width % within a 50% half + +-- One label:value pair in the left half, right half empty +function UI.mainRow1(pg, label, valueFn) + local row = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, thickness = 0, + flexFlow = lvgl.FLOW_ROW, flexPad = 0, + }) + -- Left half + row:rectangle({ + w = lvgl.PERCENT_SIZE + ML, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=label, font=BOLD, color=BLACK}}, + }) + row:rectangle({ + w = lvgl.PERCENT_SIZE + (100 - ML), h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=valueFn}}, + }) +end + +-- Two label:value pairs side by side (each gets 50%) +function UI.mainRow2(pg, label1, val1, label2, val2) + local row = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, thickness = 0, + flexFlow = lvgl.FLOW_ROW, flexPad = 0, + }) + -- Left half + local left = row:rectangle({ + w = lvgl.PERCENT_SIZE + 50, thickness = 0, + flexFlow = lvgl.FLOW_ROW, flexPad = 0, + }) + left:rectangle({ + w = lvgl.PERCENT_SIZE + ML, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=label1, font=BOLD, color=BLACK}}, + }) + left:rectangle({ + w = lvgl.PERCENT_SIZE + MV, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=val1}}, + }) + -- Right half + local right = row:rectangle({ + w = lvgl.PERCENT_SIZE + 50, thickness = 0, + flexFlow = lvgl.FLOW_ROW, flexPad = 0, + }) + right:rectangle({ + w = lvgl.PERCENT_SIZE + ML, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=label2, font=BOLD, color=BLACK}}, + }) + right:rectangle({ + w = lvgl.PERCENT_SIZE + MV, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=val2}}, + }) +end + +-- Two label:control pairs side by side (each gets 50%) +function UI.mainControlRow2(pg, label1, ctrl1Fn, label2, ctrl2Fn) + local row = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, thickness = 0, + flexFlow = lvgl.FLOW_ROW, flexPad = 0, + }) + -- Left half + local left = row:rectangle({ + w = lvgl.PERCENT_SIZE + 50, thickness = 0, + flexFlow = lvgl.FLOW_ROW, flexPad = 0, + }) + left:rectangle({ + w = lvgl.PERCENT_SIZE + ML, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=label1, font=BOLD, color=BLACK}}, + }) + local leftCtrl = left:rectangle({ + w = lvgl.PERCENT_SIZE + MV, thickness = 0, + flexFlow = lvgl.FLOW_ROW, flexPad = 0, + }) + ctrl1Fn(leftCtrl) + -- Right half + local right = row:rectangle({ + w = lvgl.PERCENT_SIZE + 50, thickness = 0, + flexFlow = lvgl.FLOW_ROW, flexPad = 0, + }) + right:rectangle({ + w = lvgl.PERCENT_SIZE + ML, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=label2, font=BOLD, color=BLACK}}, + }) + local rightCtrl = right:rectangle({ + w = lvgl.PERCENT_SIZE + MV, thickness = 0, + flexFlow = lvgl.FLOW_ROW, flexPad = 0, + }) + ctrl2Fn(rightCtrl) +end + +---------------------------------------------------------------------- +-- UI: Page builders +---------------------------------------------------------------------- + +function UI.buildMainPage() + UI.currentPage = lvgl.page({ + title = "mLRS Configurator", + subtitle = UI.getMainSubtitle, + back = function() App.shouldExit = true end, + }) + + local pg = UI.currentPage:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + }) + + -- Version check + if Protocol.DEVICE_ITEM_TX ~= nil and Protocol.DEVICE_ITEM_TX.version_int < VERSION.required_tx_version_int then + Dialogs.showVersionWarning("Tx version not supported by this Lua script!") + end + if Protocol.DEVICE_ITEM_RX ~= nil and Protocol.connected and Protocol.DEVICE_ITEM_RX.version_int < VERSION.required_rx_version_int then + Dialogs.showVersionWarning("Rx version not supported by this Lua script!") + end + + -- Line 1: Tx info + UI.mainRow1(pg, "Tx:", function() + if Protocol.DEVICE_ITEM_TX == nil then return "---" end + local s = Protocol.DEVICE_ITEM_TX.name .. " " .. Protocol.DEVICE_ITEM_TX.version_str + if Protocol.DEVICE_INFO ~= nil then + s = s .. " ConfigId " .. tostring(Protocol.DEVICE_INFO.tx_config_id) + end + return s + end) + + -- Line 2: Tx Power + Tx Diversity + UI.mainRow2(pg, + "Tx Power:", function() + if Protocol.DEVICE_INFO == nil then return "---" end + return tostring(Protocol.DEVICE_INFO.tx_power_dbm) .. " dBm" + end, + "Tx Diversity:", function() + if Protocol.DEVICE_INFO == nil then return "---" end + return diversity_list[Protocol.DEVICE_INFO.tx_diversity] or "?" + end) + + -- Line 3: Rx info + UI.mainRow1(pg, "Rx:", function() + if not Protocol.connected then return "not connected" end + if Protocol.DEVICE_ITEM_RX == nil then return "---" end + return Protocol.DEVICE_ITEM_RX.name .. " " .. Protocol.DEVICE_ITEM_RX.version_str + end) + + -- Line 4: Rx Power + Rx Diversity + UI.mainRow2(pg, + "Rx Power:", function() + if Protocol.DEVICE_INFO == nil or not Protocol.connected then return "---" end + return tostring(Protocol.DEVICE_INFO.rx_power_dbm) .. " dBm" + end, + "Rx Diversity:", function() + if Protocol.DEVICE_INFO == nil or not Protocol.connected then return "---" end + return diversity_list[Protocol.DEVICE_INFO.rx_diversity] or "?" + end) + + -- Line 5: Sensitivity + UI.mainRow1(pg, "Sensitivity:", function() + if Protocol.DEVICE_INFO == nil then return "---" end + return tostring(Protocol.DEVICE_INFO.receiver_sensitivity) .. " dBm" + end) + + -- Line 6: Bind Phrase + Mode + if Protocol.DEVICE_PARAM_LIST_complete then + local bpParam = Protocol.DEVICE_PARAM_LIST[0] + local modeParam = Protocol.DEVICE_PARAM_LIST[1] + + UI.mainControlRow2(pg, + "Bind Phrase:", + function(parent) + if bpParam then + parent:textEdit({ + value = bpParam.value or "", + length = 6, + w = lvgl.PERCENT_SIZE + 90, + set = function(newVal) + local s = "" + for i = 1, math.min(6, #newVal) do + local c = string.sub(newVal, i, i) + if string.find(bindphrase_chars, c, 1, true) then s = s .. c end + end + while #s < 6 do s = s .. "a" end + bpParam.value = s + Protocol.sendParamSet(0) + end, + }) + end + end, + "Mode:", + function(parent) + if Protocol.paramFocusable(1) then + local mfv, mo2f, mf2o = UI.buildFilteredValues(modeParam) + parent:choice({ + values = mfv, + get = function() return mo2f[modeParam.value] or 1 end, + set = function(val) modeParam.value = mf2o[val] or 0; Protocol.sendParamSet(1) end, + active = function() return modeParam.editable end, + }) + end + end) + + -- Line 7: RF Band + Ortho + local rfParam = Protocol.DEVICE_PARAM_LIST[2] + local orthoParam = Protocol.DEVICE_PARAM_LIST[3] + + UI.mainControlRow2(pg, + "RF Band:", + function(parent) + if Protocol.paramFocusable(2) then + local rfv, ro2f, rf2o = UI.buildFilteredValues(rfParam, freq_band_list) + parent:choice({ + values = rfv, + get = function() return ro2f[rfParam.value] or 1 end, + set = function(val) rfParam.value = rf2o[val] or 0; Protocol.sendParamSet(2) end, + active = function() return rfParam.editable end, + }) + end + end, + "Ortho:", + function(parent) + if orthoParam and orthoParam.allowed_mask > 0 and Protocol.paramFocusable(3) then + local ofv, oo2f, of2o = UI.buildFilteredValues(orthoParam) + parent:choice({ + values = ofv, + get = function() return oo2f[orthoParam.value] or 1 end, + set = function(val) orthoParam.value = of2o[val] or 0; Protocol.sendParamSet(3) end, + active = function() return orthoParam.editable end, + }) + end + end) + end + + -- Line 8: All 6 action buttons + local btnRow = pg:box({ + w = lvgl.PERCENT_SIZE + 100, flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + }) + btnRow:button({ text = "Edit Tx", w = lvgl.PERCENT_SIZE + 16, press = function() + if Protocol.DEVICE_PARAM_LIST_complete then App.switchPage("edit_tx") end + end }) + btnRow:button({ text = "Edit Rx", w = lvgl.PERCENT_SIZE + 16, press = function() + if Protocol.DEVICE_PARAM_LIST_complete and Protocol.connected then App.switchPage("edit_rx") end + end, active = function() return Protocol.connected and Protocol.DEVICE_PARAM_LIST_complete end }) + btnRow:button({ text = "Save", w = lvgl.PERCENT_SIZE + 16, press = function() + if Protocol.DEVICE_PARAM_LIST_complete then Protocol.sendParamStore(); Protocol.clearParams() end + end }) + btnRow:button({ text = "Reload", w = lvgl.PERCENT_SIZE + 16, press = function() + Protocol.clearParams(); UI.invalidate() + end }) + btnRow:button({ text = "Bind", w = lvgl.PERCENT_SIZE + 16, press = function() + Protocol.sendBind() + end }) + btnRow:button({ text = "Tools", w = lvgl.PERCENT_SIZE + 16, press = function() + if Protocol.DEVICE_PARAM_LIST_complete then App.switchPage("tools") end + end }) + + -- Setup layout warning + if Protocol.DEVICE_ITEM_TX ~= nil and Protocol.DEVICE_ITEM_RX ~= nil and Protocol.connected and Protocol.DEVICE_PARAM_LIST_complete and + (Protocol.DEVICE_ITEM_TX.setuplayout_int > 515 or Protocol.DEVICE_ITEM_RX.setuplayout_int > 515) then + if Protocol.DEVICE_ITEM_TX.setuplayout_int < Protocol.DEVICE_ITEM_RX.setuplayout_int then + pg:build({{type="label", text="Tx param version < Rx. Update Tx firmware!", color=COLOR_THEME_PRIMARY1}}) + elseif Protocol.DEVICE_ITEM_RX.setuplayout_int < Protocol.DEVICE_ITEM_TX.setuplayout_int then + pg:build({{type="label", text="Rx param version < Tx. Update Rx firmware!", color=COLOR_THEME_PRIMARY1}}) + end + end + + pg:rectangle({w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_SMALL, thickness = 0}) +end + + +function UI.buildEditPage(prefix) + local title = "mLRS: Edit " .. prefix + + local pageOptions = { + title = title, + subtitle = UI.getSubtitle, + backButton = true, + back = function() + App.switchPage("main") + end, + } + + UI.currentPage = lvgl.page(pageOptions) + + local container = UI.currentPage:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_TINY, + }) + + -- Header + if prefix == "Tx" then + local headerText = "Tx" + if Protocol.DEVICE_INFO ~= nil then + headerText = "Tx - " .. tostring(Protocol.DEVICE_INFO.tx_config_id) .. ":" + end + container:build({{type="label", text=headerText, color=COLOR_THEME_PRIMARY1, font=BOLD}}) + else + container:build({{type="label", text="Rx:", color=COLOR_THEME_PRIMARY1, font=BOLD}}) + end + + if not Protocol.DEVICE_PARAM_LIST_complete then + container:build({{type="label", text="Parameters not loaded"}}) + return + end + + -- Iterate parameters, filter by prefix + for pidx = 2, #Protocol.DEVICE_PARAM_LIST do + local p = Protocol.DEVICE_PARAM_LIST[pidx] + if p ~= nil and string.sub(p.name, 1, 2) == prefix and p.allowed_mask > 0 then + if p.typ < MBRIDGE_PARAM_TYPE.LIST then + UI.createNumberRow(container, p, pidx) + elseif p.typ == MBRIDGE_PARAM_TYPE.LIST then + local displayName = string.sub(p.name, 4) + UI.createFilteredChoiceRow(container, displayName, p, pidx) + end + end + end + + container:rectangle({w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_SMALL, thickness = 0}) +end + + +function UI.buildToolsPage() + local pageOptions = { + title = "mLRS: Tools", + backButton = true, + back = function() + App.switchPage("main") + end, + } + + UI.currentPage = lvgl.page(pageOptions) + + local container = UI.currentPage:box({ + w = lvgl.PERCENT_SIZE + 100, + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_MEDIUM, + }) + + container:button({ + text = "System Bootloader", + w = lvgl.PERCENT_SIZE + 100, + press = function() + Protocol.sendBoot() + App.switchPage("main") + end, + }) + + container:button({ + text = "Flash ESP", + w = lvgl.PERCENT_SIZE + 100, + press = function() + Protocol.sendFlashEsp() + App.switchPage("main") + end, + }) +end + + +---------------------------------------------------------------------- +-- UI: Build dispatcher +---------------------------------------------------------------------- + +function UI.build() + -- Close any open dialog before clearing - dialogs may be on a separate + -- LVGL layer that lvgl.clear() does not destroy + if Dialogs.popup and Dialogs.popup.dialog then + Dialogs.popup.dialog:close() + Dialogs.popup = nil + end + lvgl.clear() + + if App.page == "main" then + UI.buildMainPage() + elseif App.page == "edit_tx" then + UI.buildEditPage("Tx") + elseif App.page == "edit_rx" then + UI.buildEditPage("Rx") + elseif App.page == "tools" then + UI.buildToolsPage() + end + + UI.uiBuilt = true +end + + +-- ============================================================================ +-- App: Page switching +-- ============================================================================ + +function App.switchPage(name) + App.page = name + UI.invalidate() +end + + +-- ============================================================================ +-- LVGL support check fallback +-- ============================================================================ + +local function showLvglRequired() + lcd.clear() + lcd.drawText(10, 10, "LVGL support required", MIDSIZE) + lcd.drawText(10, 30, "Color LCD radio with", 0) + lcd.drawText(10, 50, "EdgeTX 2.11.5+ needed", 0) +end + + +-- ============================================================================ +-- Init +-- ============================================================================ + +local function init() + if lvgl == nil then + return + end + + Protocol.setupBridge() + + local tnow_10ms = getTime() + + Protocol.DEVICE_DOWNLOAD_is_running = true + App.isFirstParamDownload = true + App.firstParamDownloadTmo_10ms = tnow_10ms + 500 + + if tnow_10ms < 300 then + Protocol.DEVICE_SAVE_t_last = 300 - tnow_10ms + else + Protocol.DEVICE_SAVE_t_last = 0 + end +end + + +-- ============================================================================ +-- Run (main coordinator) +-- ============================================================================ + +local function run(event, touchState) + if event == nil then return 2 end + + -- Check for LVGL support + if lvgl == nil then + showLvglRequired() + return 0 + end + + -- Check for mBridge/CRSF module + if not App.moduleChecked then + App.moduleChecked = true + if Protocol.isConnected == nil then + if mbridge == nil or not mbridge.enabled() then + if model.getModule(0).Type ~= 5 and model.getModule(1).Type ~= 5 then + lvgl.clear() + local dg = lvgl.dialog({ + title = "mLRS: No Module Found", + flexFlow = lvgl.FLOW_COLUMN, + flexPad = lvgl.PAD_SMALL, + close = function() App.shouldExit = true end, + }) + dg:build({ + {type="label", text="mLRS not accessible:"}, + {type="label", text="mBridge or CRSF not enabled!"}, + {type="box", flexFlow=lvgl.FLOW_ROW, w=lvgl.PERCENT_SIZE+100, align=CENTER, children={ + {type="button", text="Exit", w=lvgl.PERCENT_SIZE+98, press=function() + dg:close() + App.shouldExit = true + end}, + }}, + }) + UI.uiBuilt = true + end + end + end + end + + if App.shouldExit then return 2 end + + if Protocol.isConnected == nil then + return 0 + end + + -- Protocol polling + Protocol.doConnected() + + -- Connection state transitions + if Protocol.has_connected then + Protocol.clearParams() + if Dialogs.popup == nil then Dialogs.showPopup("Receiver connected!", 100) end + Dialogs.clearIfBlocked() + end + if Protocol.has_disconnected then + if Dialogs.popup == nil then Dialogs.showPopup("Receiver\nhas disconnected!", 100) end + end + if not Protocol.connected and App.page == "edit_rx" then + App.switchPage("main") + end + + Protocol.doParamLoop() + Protocol.checkBind() + Dialogs.update() + + -- CRSF baudrate warning on first param download + if App.isFirstParamDownload and Protocol.DEVICE_DOWNLOAD_is_running then + if App.firstParamDownloadTmo_10ms > 0 and getTime() > App.firstParamDownloadTmo_10ms then + if not App.baudRateWarningShown then + App.baudRateWarningShown = true + Dialogs.showPopup("Please check if CRSF\nbaudrate is 400k!", 300) + end + App.firstParamDownloadTmo_10ms = 0 + end + end + + -- Detect param loading state transitions + local paramsNowComplete = Protocol.DEVICE_PARAM_LIST_complete + if paramsNowComplete ~= UI.paramsWereComplete then + if paramsNowComplete then + App.isFirstParamDownload = false + collectgarbage("collect") + end + UI.invalidate() + end + UI.paramsWereComplete = paramsNowComplete + + -- Build/rebuild UI when needed (skip while popup is showing) + if not UI.uiBuilt and Dialogs.popup == nil then + UI.build() + end + + if App.shouldExit then return 2 end + + return 0 +end + + +-- ============================================================================ +-- Return +-- ============================================================================ + +return { init = init, run = run, useLvgl = true } From 6ca78e506157ced2bd6528efadc6b0e0782abab0 Mon Sep 17 00:00:00 2001 From: JLP Date: Tue, 14 Apr 2026 08:34:21 -0400 Subject: [PATCH 2/3] Some more --- lua/mLRS-LVGL.lua | 336 ++++++++++++++++++++-------------------------- 1 file changed, 145 insertions(+), 191 deletions(-) diff --git a/lua/mLRS-LVGL.lua b/lua/mLRS-LVGL.lua index df6bf462c..71e2dbda5 100644 --- a/lua/mLRS-LVGL.lua +++ b/lua/mLRS-LVGL.lua @@ -3,17 +3,16 @@ -- Copyright (c) MLRS project -- GPL3 -- https://www.gnu.org/licenses/gpl-3.0.de.html --- OlliW @ www.olliw.eu ---------------------------------------------------------------------- --- Lua TOOLS script, LVGL version for EdgeTX 2.11.5+ +-- Lua TOOLS script, LVGL version for EdgeTX 2.12.X+ ---------------------------------------------------------------------- -- copy script to SCRIPTS\TOOLS folder on EdgeTx SD card --- works with mLRS v1.3.03 and later +-- works with mLRS v1.4.00 and later local VERSION = { - script = '2026-04-13 LVGL', - required_tx_version_int = 10303, -- 'v1.3.03' - required_rx_version_int = 10303, -- 'v1.3.03' + script = '2026-04-14 LVGL', + required_tx_version_int = 10400, -- 'v1.4.00' + required_rx_version_int = 10400, -- 'v1.4.00' } @@ -70,6 +69,9 @@ local Protocol = { DEVICE_PARAM_LIST_complete = false, DEVICE_DOWNLOAD_is_running = true, DEVICE_SAVE_t_last = 0, + DEVICE_SAVE_reload_pending = false, + DEVICE_BINDING = false, + DEVICE_BIND_t_start = 0, } local MBRIDGE_COMMANDPACKET_STX = 0xA0 @@ -452,7 +454,7 @@ function Protocol.doParamLoop() Protocol.DEVICE_PARAM_LIST_complete = true else Protocol.DEVICE_PARAM_LIST_complete = false - Dialogs.showPopup("Param Upload Errors ("..tostring(Protocol.DEVICE_PARAM_LIST_errors)..")!\nTry Reload", 200) + UI.setSubtitleMsg("Param errors ("..tostring(Protocol.DEVICE_PARAM_LIST_errors)..")! Try Reload", 200) end Protocol.DEVICE_DOWNLOAD_is_running = false else @@ -555,13 +557,14 @@ function Protocol.sendParamStore() if not Protocol.DEVICE_PARAM_LIST_complete then return end Protocol.cmdPush(MBRIDGE_CMD.PARAM_STORE, {}) Protocol.DEVICE_SAVE_t_last = getTime() - Dialogs.showPopup("Save Parameters", 250) + Protocol.DEVICE_SAVE_reload_pending = true end function Protocol.sendBind() if Protocol.DEVICE_DOWNLOAD_is_running then return end Protocol.cmdPush(MBRIDGE_CMD.BIND_START, {}) - Dialogs.showBlocked("Binding") + Protocol.DEVICE_BINDING = true + Protocol.DEVICE_BIND_t_start = getTime() end function Protocol.sendBoot() @@ -579,7 +582,7 @@ end function Protocol.checkBind() if Protocol.DEVICE_DOWNLOAD_is_running then return end if Protocol.DEVICE_INFO ~= nil and Protocol.DEVICE_INFO.has_status == 1 and Protocol.DEVICE_INFO.binding == 1 then - Dialogs.showBlocked("Binding") + Protocol.DEVICE_BINDING = true end end @@ -635,37 +638,7 @@ end -- Dialogs Module -- ============================================================================ -Dialogs.popup = nil -- {dialog, tend_10ms} - -function Dialogs.showPopup(text, tmo_10ms) - if Dialogs.popup and Dialogs.popup.tend_10ms and Dialogs.popup.tend_10ms < 0 then - return -- blocked popup on display, don't override - end - -- Close existing popup if any - if Dialogs.popup and Dialogs.popup.dialog then - Dialogs.popup.dialog:close() - end - local dg = lvgl.dialog({ - title = "mLRS", - flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, - }) - -- Handle multi-line text - local i = string.find(text, "\n") - if i == nil then - dg:build({ - {type="label", text=text, align=CENTER, font=MIDSIZE}, - }) - else - local t1 = string.sub(text, 1, i-1) - local t2 = string.sub(text, i+1) - dg:build({ - {type="label", text=t1, align=CENTER, font=MIDSIZE}, - {type="label", text=t2, align=CENTER, font=MIDSIZE}, - }) - end - Dialogs.popup = {dialog = dg, tend_10ms = getTime() + tmo_10ms} -end +Dialogs.popup = nil -- {dialog} function Dialogs.showBlocked(text) -- Close existing popup if any @@ -675,37 +648,21 @@ function Dialogs.showBlocked(text) local dg = lvgl.dialog({ title = "mLRS", flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, + flexPad = lvgl.PAD_TINY, }) dg:build({ {type="label", text=text, align=CENTER, font=MIDSIZE}, }) - Dialogs.popup = {dialog = dg, tend_10ms = -1} + Dialogs.popup = {dialog = dg} end -function Dialogs.clearPopup() +function Dialogs.clearBlocked() if Dialogs.popup and Dialogs.popup.dialog then Dialogs.popup.dialog:close() end Dialogs.popup = nil end -function Dialogs.clearIfBlocked() - if Dialogs.popup and Dialogs.popup.tend_10ms and Dialogs.popup.tend_10ms < 0 then - Dialogs.clearPopup() - end -end - -function Dialogs.update() - if Dialogs.popup == nil then return end - if Dialogs.popup.tend_10ms > 0 then - local t_10ms = getTime() - if t_10ms > Dialogs.popup.tend_10ms then - Dialogs.clearPopup() - end - end -end - function Dialogs.showVersionWarning(text) lvgl.message({ title = "Version Warning", @@ -722,12 +679,19 @@ local UI = { currentPage = nil, uiBuilt = false, paramsWereComplete = false, + subtitle_msg = nil, + subtitle_msg_tend = 0, } function UI.invalidate() UI.uiBuilt = false end +function UI.setSubtitleMsg(msg, tmo_10ms) + UI.subtitle_msg = msg + UI.subtitle_msg_tend = getTime() + tmo_10ms +end + ---------------------------------------------------------------------- -- UI: Subtitle @@ -747,6 +711,12 @@ function UI.getSubtitle() end function UI.getMainSubtitle() + if Protocol.DEVICE_BINDING then return "Binding..." end + if Protocol.DEVICE_SAVE_reload_pending then return "Saving..." end + if UI.subtitle_msg then + if getTime() < UI.subtitle_msg_tend then return UI.subtitle_msg end + UI.subtitle_msg = nil + end local s = UI.getSubtitle() if s ~= "" then return s end return VERSION.script @@ -964,7 +934,6 @@ function UI.mainRow1(pg, label, valueFn) w = lvgl.PERCENT_SIZE + 100, thickness = 0, flexFlow = lvgl.FLOW_ROW, flexPad = 0, }) - -- Left half row:rectangle({ w = lvgl.PERCENT_SIZE + ML, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=label, font=BOLD, color=BLACK}}, @@ -975,6 +944,26 @@ function UI.mainRow1(pg, label, valueFn) }) end +-- One label:control pair (full width) +function UI.mainControlRow1(pg, label, ctrlFn) + local row = pg:rectangle({ + w = lvgl.PERCENT_SIZE + 100, thickness = 0, + flexFlow = lvgl.FLOW_ROW, flexPad = 0, + }) + row:rectangle({ + w = lvgl.PERCENT_SIZE + ML, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=label, font=BOLD, color=BLACK}}, + }) + local ctrl = row:rectangle({ + w = lvgl.PERCENT_SIZE + (100 - ML), thickness = 0, + flexFlow = lvgl.FLOW_ROW, flexPad = 0, + align = LEFT, + }) + ctrlFn(ctrl) + -- Invisible placeholder keeps height consistent when no control is added + ctrl:rectangle({w = 1, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0}) +end + -- Two label:value pairs side by side (each gets 50%) function UI.mainRow2(pg, label1, val1, label2, val2) local row = pg:rectangle({ @@ -1059,7 +1048,7 @@ function UI.buildMainPage() local pg = UI.currentPage:box({ w = lvgl.PERCENT_SIZE + 100, flexFlow = lvgl.FLOW_COLUMN, - flexPad = lvgl.PAD_SMALL, + flexPad = lvgl.PAD_TINY, }) -- Version check @@ -1070,122 +1059,7 @@ function UI.buildMainPage() Dialogs.showVersionWarning("Rx version not supported by this Lua script!") end - -- Line 1: Tx info - UI.mainRow1(pg, "Tx:", function() - if Protocol.DEVICE_ITEM_TX == nil then return "---" end - local s = Protocol.DEVICE_ITEM_TX.name .. " " .. Protocol.DEVICE_ITEM_TX.version_str - if Protocol.DEVICE_INFO ~= nil then - s = s .. " ConfigId " .. tostring(Protocol.DEVICE_INFO.tx_config_id) - end - return s - end) - - -- Line 2: Tx Power + Tx Diversity - UI.mainRow2(pg, - "Tx Power:", function() - if Protocol.DEVICE_INFO == nil then return "---" end - return tostring(Protocol.DEVICE_INFO.tx_power_dbm) .. " dBm" - end, - "Tx Diversity:", function() - if Protocol.DEVICE_INFO == nil then return "---" end - return diversity_list[Protocol.DEVICE_INFO.tx_diversity] or "?" - end) - - -- Line 3: Rx info - UI.mainRow1(pg, "Rx:", function() - if not Protocol.connected then return "not connected" end - if Protocol.DEVICE_ITEM_RX == nil then return "---" end - return Protocol.DEVICE_ITEM_RX.name .. " " .. Protocol.DEVICE_ITEM_RX.version_str - end) - - -- Line 4: Rx Power + Rx Diversity - UI.mainRow2(pg, - "Rx Power:", function() - if Protocol.DEVICE_INFO == nil or not Protocol.connected then return "---" end - return tostring(Protocol.DEVICE_INFO.rx_power_dbm) .. " dBm" - end, - "Rx Diversity:", function() - if Protocol.DEVICE_INFO == nil or not Protocol.connected then return "---" end - return diversity_list[Protocol.DEVICE_INFO.rx_diversity] or "?" - end) - - -- Line 5: Sensitivity - UI.mainRow1(pg, "Sensitivity:", function() - if Protocol.DEVICE_INFO == nil then return "---" end - return tostring(Protocol.DEVICE_INFO.receiver_sensitivity) .. " dBm" - end) - - -- Line 6: Bind Phrase + Mode - if Protocol.DEVICE_PARAM_LIST_complete then - local bpParam = Protocol.DEVICE_PARAM_LIST[0] - local modeParam = Protocol.DEVICE_PARAM_LIST[1] - - UI.mainControlRow2(pg, - "Bind Phrase:", - function(parent) - if bpParam then - parent:textEdit({ - value = bpParam.value or "", - length = 6, - w = lvgl.PERCENT_SIZE + 90, - set = function(newVal) - local s = "" - for i = 1, math.min(6, #newVal) do - local c = string.sub(newVal, i, i) - if string.find(bindphrase_chars, c, 1, true) then s = s .. c end - end - while #s < 6 do s = s .. "a" end - bpParam.value = s - Protocol.sendParamSet(0) - end, - }) - end - end, - "Mode:", - function(parent) - if Protocol.paramFocusable(1) then - local mfv, mo2f, mf2o = UI.buildFilteredValues(modeParam) - parent:choice({ - values = mfv, - get = function() return mo2f[modeParam.value] or 1 end, - set = function(val) modeParam.value = mf2o[val] or 0; Protocol.sendParamSet(1) end, - active = function() return modeParam.editable end, - }) - end - end) - - -- Line 7: RF Band + Ortho - local rfParam = Protocol.DEVICE_PARAM_LIST[2] - local orthoParam = Protocol.DEVICE_PARAM_LIST[3] - - UI.mainControlRow2(pg, - "RF Band:", - function(parent) - if Protocol.paramFocusable(2) then - local rfv, ro2f, rf2o = UI.buildFilteredValues(rfParam, freq_band_list) - parent:choice({ - values = rfv, - get = function() return ro2f[rfParam.value] or 1 end, - set = function(val) rfParam.value = rf2o[val] or 0; Protocol.sendParamSet(2) end, - active = function() return rfParam.editable end, - }) - end - end, - "Ortho:", - function(parent) - if orthoParam and orthoParam.allowed_mask > 0 and Protocol.paramFocusable(3) then - local ofv, oo2f, of2o = UI.buildFilteredValues(orthoParam) - parent:choice({ - values = ofv, - get = function() return oo2f[orthoParam.value] or 1 end, - set = function(val) orthoParam.value = of2o[val] or 0; Protocol.sendParamSet(3) end, - active = function() return orthoParam.editable end, - }) - end - end) - end - - -- Line 8: All 6 action buttons + -- Action buttons local btnRow = pg:box({ w = lvgl.PERCENT_SIZE + 100, flexFlow = lvgl.FLOW_ROW, flexPad = lvgl.PAD_TINY, @@ -1197,7 +1071,7 @@ function UI.buildMainPage() if Protocol.DEVICE_PARAM_LIST_complete and Protocol.connected then App.switchPage("edit_rx") end end, active = function() return Protocol.connected and Protocol.DEVICE_PARAM_LIST_complete end }) btnRow:button({ text = "Save", w = lvgl.PERCENT_SIZE + 16, press = function() - if Protocol.DEVICE_PARAM_LIST_complete then Protocol.sendParamStore(); Protocol.clearParams() end + if Protocol.DEVICE_PARAM_LIST_complete then Protocol.sendParamStore() end end }) btnRow:button({ text = "Reload", w = lvgl.PERCENT_SIZE + 16, press = function() Protocol.clearParams(); UI.invalidate() @@ -1209,6 +1083,85 @@ function UI.buildMainPage() if Protocol.DEVICE_PARAM_LIST_complete then App.switchPage("tools") end end }) + -- Tx, Rx, Bind Phrase, Mode, RF Band, Ortho (consistent spacing) + local ctrlBox = pg:box({ + w = lvgl.PERCENT_SIZE + 100, flexFlow = lvgl.FLOW_COLUMN, flexPad = 5, + }) + UI.mainRow1(ctrlBox, "Tx:", function() + if Protocol.DEVICE_ITEM_TX == nil then return "---" end + local s = Protocol.DEVICE_ITEM_TX.name .. " | " .. Protocol.DEVICE_ITEM_TX.version_str + if Protocol.DEVICE_INFO ~= nil then + s = s .. " | ConfigId " .. tostring(Protocol.DEVICE_INFO.tx_config_id) + end + return s + end) + UI.mainRow1(ctrlBox, "Rx:", function() + if not Protocol.connected then return "not connected" end + if Protocol.DEVICE_ITEM_RX == nil then return "---" end + return Protocol.DEVICE_ITEM_RX.name .. " | " .. Protocol.DEVICE_ITEM_RX.version_str + end) + local bpParam = Protocol.DEVICE_PARAM_LIST_complete and Protocol.DEVICE_PARAM_LIST[0] or nil + local modeParam = Protocol.DEVICE_PARAM_LIST_complete and Protocol.DEVICE_PARAM_LIST[1] or nil + local rfParam = Protocol.DEVICE_PARAM_LIST_complete and Protocol.DEVICE_PARAM_LIST[2] or nil + local orthoParam = Protocol.DEVICE_PARAM_LIST_complete and Protocol.DEVICE_PARAM_LIST[3] or nil + + UI.mainControlRow1(ctrlBox, "Bind Phrase:", function(parent) + if bpParam then + parent:textEdit({ + value = bpParam.value or "", + length = 6, + w = 80, + set = function(newVal) + local s = "" + for i = 1, math.min(6, #newVal) do + local c = string.sub(newVal, i, i) + if string.find(bindphrase_chars, c, 1, true) then s = s .. c end + end + while #s < 6 do s = s .. "a" end + bpParam.value = s + Protocol.sendParamSet(0) + end, + }) + end + end) + + UI.mainControlRow1(ctrlBox, "Mode:", function(parent) + if modeParam and Protocol.paramFocusable(1) then + local mfv, mo2f, mf2o = UI.buildFilteredValues(modeParam) + parent:choice({ + values = mfv, + get = function() return mo2f[modeParam.value] or 1 end, + set = function(val) modeParam.value = mf2o[val] or 0; Protocol.sendParamSet(1) end, + active = function() return modeParam.editable end, + }) + end + end) + + UI.mainControlRow1(ctrlBox, "RF Band:", function(parent) + if rfParam and Protocol.paramFocusable(2) then + local rfv, ro2f, rf2o = UI.buildFilteredValues(rfParam, freq_band_list) + parent:choice({ + values = rfv, + get = function() return ro2f[rfParam.value] or 1 end, + set = function(val) rfParam.value = rf2o[val] or 0; Protocol.sendParamSet(2) end, + active = function() return rfParam.editable end, + }) + end + end) + + -- Ortho row only shown when available + if orthoParam and orthoParam.allowed_mask > 0 and Protocol.paramFocusable(3) then + UI.mainControlRow1(ctrlBox, "Ortho:", function(parent) + local ofv, oo2f, of2o = UI.buildFilteredValues(orthoParam) + parent:choice({ + values = ofv, + get = function() return oo2f[orthoParam.value] or 1 end, + set = function(val) orthoParam.value = of2o[val] or 0; Protocol.sendParamSet(3) end, + active = function() return orthoParam.editable end, + }) + end) + end + -- Setup layout warning if Protocol.DEVICE_ITEM_TX ~= nil and Protocol.DEVICE_ITEM_RX ~= nil and Protocol.connected and Protocol.DEVICE_PARAM_LIST_complete and (Protocol.DEVICE_ITEM_TX.setuplayout_int > 515 or Protocol.DEVICE_ITEM_RX.setuplayout_int > 515) then @@ -1218,8 +1171,6 @@ function UI.buildMainPage() pg:build({{type="label", text="Rx param version < Tx. Update Rx firmware!", color=COLOR_THEME_PRIMARY1}}) end end - - pg:rectangle({w = lvgl.PERCENT_SIZE + 100, h = lvgl.PAD_SMALL, thickness = 0}) end @@ -1320,10 +1271,7 @@ end function UI.build() -- Close any open dialog before clearing - dialogs may be on a separate -- LVGL layer that lvgl.clear() does not destroy - if Dialogs.popup and Dialogs.popup.dialog then - Dialogs.popup.dialog:close() - Dialogs.popup = nil - end + Dialogs.clearBlocked() lvgl.clear() if App.page == "main" then @@ -1441,11 +1389,11 @@ local function run(event, touchState) -- Connection state transitions if Protocol.has_connected then Protocol.clearParams() - if Dialogs.popup == nil then Dialogs.showPopup("Receiver connected!", 100) end - Dialogs.clearIfBlocked() + Protocol.DEVICE_BINDING = false + UI.setSubtitleMsg("Receiver connected!", 100) end if Protocol.has_disconnected then - if Dialogs.popup == nil then Dialogs.showPopup("Receiver\nhas disconnected!", 100) end + UI.setSubtitleMsg("Receiver disconnected!", 100) end if not Protocol.connected and App.page == "edit_rx" then App.switchPage("main") @@ -1453,14 +1401,20 @@ local function run(event, touchState) Protocol.doParamLoop() Protocol.checkBind() - Dialogs.update() + + -- Auto-reload params after save dead time expires + if Protocol.DEVICE_SAVE_reload_pending and getTime() > Protocol.DEVICE_SAVE_t_last + paramLoadDeadTime_10ms then + Protocol.DEVICE_SAVE_reload_pending = false + Protocol.clearParams() + UI.invalidate() + end -- CRSF baudrate warning on first param download if App.isFirstParamDownload and Protocol.DEVICE_DOWNLOAD_is_running then if App.firstParamDownloadTmo_10ms > 0 and getTime() > App.firstParamDownloadTmo_10ms then if not App.baudRateWarningShown then App.baudRateWarningShown = true - Dialogs.showPopup("Please check if CRSF\nbaudrate is 400k!", 300) + UI.setSubtitleMsg("Check CRSF baudrate is 400k!", 300) end App.firstParamDownloadTmo_10ms = 0 end @@ -1477,7 +1431,7 @@ local function run(event, touchState) end UI.paramsWereComplete = paramsNowComplete - -- Build/rebuild UI when needed (skip while popup is showing) + -- Build/rebuild UI when needed (skip while blocked dialog is showing) if not UI.uiBuilt and Dialogs.popup == nil then UI.build() end From d8bcb6bfaa1f9df133dacd33448ae8ffacaf8ef3 Mon Sep 17 00:00:00 2001 From: JLP Date: Wed, 15 Apr 2026 14:19:29 -0400 Subject: [PATCH 3/3] ETX Simulator Notes --- lua/SIMULATOR.md | 82 +++++++++++++ lua/mLRS-LVGL.lua | 303 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 339 insertions(+), 46 deletions(-) create mode 100644 lua/SIMULATOR.md diff --git a/lua/SIMULATOR.md b/lua/SIMULATOR.md new file mode 100644 index 000000000..500834e5b --- /dev/null +++ b/lua/SIMULATOR.md @@ -0,0 +1,82 @@ +# Developing mLRS-LVGL.lua with the EdgeTX Companion Simulator + +The mLRS LVGL Lua script includes built-in mock data that activates automatically +when running in the EdgeTX Companion simulator, so you can develop and test the UI +without real radio hardware. + +## Setup + +1. **Install EdgeTX Companion** from the + [EdgeTX releases](https://github.com/EdgeTX/edgetx/releases). Pick the + version matching your target firmware (2.11.5+ for LVGL support). + +2. **Create a radio profile** in Companion: + - Settings > Radio Profile + - Select a **color LCD** radio type (TX16S, Boxer, X20S, etc.) + - Set the SD Structure Path to a folder on your computer + +3. **Copy the script** into the simulated SD card: + ``` + /SCRIPTS/TOOLS/mLRS-LVGL.lua + ``` + +4. **Launch the simulator** (click the Simulate button in Companion). + +5. **Run the script**: navigate to SYS > Tools and select + "mLRS Configurator LVGL". + +## How the Mock Works + +The script detects the simulator via `getVersion()`, which returns a string +ending in `"-simu"`. When detected, `setMock()` replaces the three protocol +bridge functions: + +| Function | Normal behaviour | Mock behaviour | +|------------------------|------------------------------------|---------------------------------------| +| `Protocol.isConnected` | Checks RSSI / mBridge link stats | Always returns `true` | +| `Protocol.cmdPush` | Sends mBridge commands via CRSF | Queues matching mock responses | +| `Protocol.cmdPop` | Reads mBridge responses via CRSF | Dequeues from the mock response queue | + +On real hardware (or when the `-simu` suffix is absent) the mock is never +loaded and has zero overhead. + +## Mock Data + +The mock provides: +- **Device info** for a "Mock Tx Module" and "Mock Rx Module" (v1.4.01) +- **17 parameters** (indices 0-16) covering all UI control types: + - Bind Phrase (STR6 text edit) + - Mode, RF Band, Ortho (LIST choices on main page) + - Tx and Rx parameters (LIST choices and UINT8 number edits on edit pages) +- `PARAM_SET` commands update mock values in memory (no persistence) +- `PARAM_STORE`, `BIND_START`, etc. are silently accepted + +## Editing Mock Data + +To change the mock parameters, edit the `mockParams` table inside `setMock()` +in `mLRS-LVGL.lua`. Each entry has: + +```lua +[index] = { + typ = 4, -- MBRIDGE_PARAM_TYPE (0=UINT8, 1=INT8, 4=LIST, 5=STR6) + name = "Tx Power", -- up to 16 chars; "Tx"/"Rx" prefix controls edit page routing + value = 2, -- initial value (number for numeric/list, string for STR6) + options = "min,10mW,50mW,100mW",-- comma-separated (LIST only, max 21 chars without ITEM3) + mask = 15, -- allowed_mask bitmask (LIST only) + min = 0, -- min value (numeric only) + max = 255, -- max value (numeric only) + unit = "kbps", -- display unit (numeric only, max 6 chars) +} +``` + +Update `MOCK_PARAM_COUNT` if you add or remove entries. + +## Tips + +- The simulator runs Lua faster than real hardware; don't rely on it for + performance profiling. +- Touch/scroll behaviour may differ slightly from a real radio. +- The initial parameter download has a ~3 second dead-time delay (same as on + real hardware) before data appears. This is normal. +- To test the disconnected state, change `Protocol.isConnected` in `setMock()` + to return `false`. diff --git a/lua/mLRS-LVGL.lua b/lua/mLRS-LVGL.lua index 71e2dbda5..0fcfd4ce7 100644 --- a/lua/mLRS-LVGL.lua +++ b/lua/mLRS-LVGL.lua @@ -925,8 +925,12 @@ end -- UI: Main page helpers (grid-aligned label:value pairs) ---------------------------------------------------------------------- -local ML = 40 -- label width % within a 50% half -local MV = 60 -- value width % within a 50% half +local ML = 25 -- label width % for full-width rows +local MV = 75 -- value width % for full-width rows +local ML2 = 40 -- left label width % within a 50% half (two-column rows) +local MV2 = 60 -- left value width % within a 50% half (two-column rows) +local ML2R = 22 -- right label width % within a 50% half +local MV2R = 78 -- right value width % within a 50% half -- One label:value pair in the left half, right half empty function UI.mainRow1(pg, label, valueFn) @@ -976,11 +980,11 @@ function UI.mainRow2(pg, label1, val1, label2, val2) flexFlow = lvgl.FLOW_ROW, flexPad = 0, }) left:rectangle({ - w = lvgl.PERCENT_SIZE + ML, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + w = lvgl.PERCENT_SIZE + ML2, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=label1, font=BOLD, color=BLACK}}, }) left:rectangle({ - w = lvgl.PERCENT_SIZE + MV, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + w = lvgl.PERCENT_SIZE + MV2, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=val1}}, }) -- Right half @@ -989,11 +993,11 @@ function UI.mainRow2(pg, label1, val1, label2, val2) flexFlow = lvgl.FLOW_ROW, flexPad = 0, }) right:rectangle({ - w = lvgl.PERCENT_SIZE + ML, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + w = lvgl.PERCENT_SIZE + ML2R, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=label2, font=BOLD, color=BLACK}}, }) right:rectangle({ - w = lvgl.PERCENT_SIZE + MV, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + w = lvgl.PERCENT_SIZE + MV2R, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=val2}}, }) end @@ -1010,11 +1014,11 @@ function UI.mainControlRow2(pg, label1, ctrl1Fn, label2, ctrl2Fn) flexFlow = lvgl.FLOW_ROW, flexPad = 0, }) left:rectangle({ - w = lvgl.PERCENT_SIZE + ML, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + w = lvgl.PERCENT_SIZE + ML2, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=label1, font=BOLD, color=BLACK}}, }) local leftCtrl = left:rectangle({ - w = lvgl.PERCENT_SIZE + MV, thickness = 0, + w = lvgl.PERCENT_SIZE + MV2, thickness = 0, flexFlow = lvgl.FLOW_ROW, flexPad = 0, }) ctrl1Fn(leftCtrl) @@ -1024,11 +1028,11 @@ function UI.mainControlRow2(pg, label1, ctrl1Fn, label2, ctrl2Fn) flexFlow = lvgl.FLOW_ROW, flexPad = 0, }) right:rectangle({ - w = lvgl.PERCENT_SIZE + ML, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, + w = lvgl.PERCENT_SIZE + ML2R, h = lvgl.UI_ELEMENT_HEIGHT, thickness = 0, children = {{type=lvgl.LABEL, y=lvgl.PAD_SMALL, text=label2, font=BOLD, color=BLACK}}, }) local rightCtrl = right:rectangle({ - w = lvgl.PERCENT_SIZE + MV, thickness = 0, + w = lvgl.PERCENT_SIZE + MV2R, thickness = 0, flexFlow = lvgl.FLOW_ROW, flexPad = 0, }) ctrl2Fn(rightCtrl) @@ -1059,30 +1063,6 @@ function UI.buildMainPage() Dialogs.showVersionWarning("Rx version not supported by this Lua script!") end - -- Action buttons - local btnRow = pg:box({ - w = lvgl.PERCENT_SIZE + 100, flexFlow = lvgl.FLOW_ROW, - flexPad = lvgl.PAD_TINY, - }) - btnRow:button({ text = "Edit Tx", w = lvgl.PERCENT_SIZE + 16, press = function() - if Protocol.DEVICE_PARAM_LIST_complete then App.switchPage("edit_tx") end - end }) - btnRow:button({ text = "Edit Rx", w = lvgl.PERCENT_SIZE + 16, press = function() - if Protocol.DEVICE_PARAM_LIST_complete and Protocol.connected then App.switchPage("edit_rx") end - end, active = function() return Protocol.connected and Protocol.DEVICE_PARAM_LIST_complete end }) - btnRow:button({ text = "Save", w = lvgl.PERCENT_SIZE + 16, press = function() - if Protocol.DEVICE_PARAM_LIST_complete then Protocol.sendParamStore() end - end }) - btnRow:button({ text = "Reload", w = lvgl.PERCENT_SIZE + 16, press = function() - Protocol.clearParams(); UI.invalidate() - end }) - btnRow:button({ text = "Bind", w = lvgl.PERCENT_SIZE + 16, press = function() - Protocol.sendBind() - end }) - btnRow:button({ text = "Tools", w = lvgl.PERCENT_SIZE + 16, press = function() - if Protocol.DEVICE_PARAM_LIST_complete then App.switchPage("tools") end - end }) - -- Tx, Rx, Bind Phrase, Mode, RF Band, Ortho (consistent spacing) local ctrlBox = pg:box({ w = lvgl.PERCENT_SIZE + 100, flexFlow = lvgl.FLOW_COLUMN, flexPad = 5, @@ -1105,7 +1085,7 @@ function UI.buildMainPage() local rfParam = Protocol.DEVICE_PARAM_LIST_complete and Protocol.DEVICE_PARAM_LIST[2] or nil local orthoParam = Protocol.DEVICE_PARAM_LIST_complete and Protocol.DEVICE_PARAM_LIST[3] or nil - UI.mainControlRow1(ctrlBox, "Bind Phrase:", function(parent) + UI.mainControlRow2(ctrlBox, "Bind Phrase:", function(parent) if bpParam then parent:textEdit({ value = bpParam.value or "", @@ -1123,9 +1103,7 @@ function UI.buildMainPage() end, }) end - end) - - UI.mainControlRow1(ctrlBox, "Mode:", function(parent) + end, "Mode:", function(parent) if modeParam and Protocol.paramFocusable(1) then local mfv, mo2f, mf2o = UI.buildFilteredValues(modeParam) parent:choice({ @@ -1137,7 +1115,7 @@ function UI.buildMainPage() end end) - UI.mainControlRow1(ctrlBox, "RF Band:", function(parent) + UI.mainControlRow2(ctrlBox, "RF Band:", function(parent) if rfParam and Protocol.paramFocusable(2) then local rfv, ro2f, rf2o = UI.buildFilteredValues(rfParam, freq_band_list) parent:choice({ @@ -1147,11 +1125,8 @@ function UI.buildMainPage() active = function() return rfParam.editable end, }) end - end) - - -- Ortho row only shown when available - if orthoParam and orthoParam.allowed_mask > 0 and Protocol.paramFocusable(3) then - UI.mainControlRow1(ctrlBox, "Ortho:", function(parent) + end, "Ortho:", function(parent) + if orthoParam and orthoParam.allowed_mask > 0 and Protocol.paramFocusable(3) then local ofv, oo2f, of2o = UI.buildFilteredValues(orthoParam) parent:choice({ values = ofv, @@ -1159,8 +1134,8 @@ function UI.buildMainPage() set = function(val) orthoParam.value = of2o[val] or 0; Protocol.sendParamSet(3) end, active = function() return orthoParam.editable end, }) - end) - end + end + end) -- Setup layout warning if Protocol.DEVICE_ITEM_TX ~= nil and Protocol.DEVICE_ITEM_RX ~= nil and Protocol.connected and Protocol.DEVICE_PARAM_LIST_complete and @@ -1171,6 +1146,30 @@ function UI.buildMainPage() pg:build({{type="label", text="Rx param version < Tx. Update Rx firmware!", color=COLOR_THEME_PRIMARY1}}) end end + + -- Action buttons (at bottom) + local btnRow = pg:box({ + w = lvgl.PERCENT_SIZE + 100, flexFlow = lvgl.FLOW_ROW, + flexPad = lvgl.PAD_TINY, + }) + btnRow:button({ text = "Edit Tx", w = lvgl.PERCENT_SIZE + 16, press = function() + if Protocol.DEVICE_PARAM_LIST_complete then App.switchPage("edit_tx") end + end }) + btnRow:button({ text = "Edit Rx", w = lvgl.PERCENT_SIZE + 16, press = function() + if Protocol.DEVICE_PARAM_LIST_complete and Protocol.connected then App.switchPage("edit_rx") end + end, active = function() return Protocol.connected and Protocol.DEVICE_PARAM_LIST_complete end }) + btnRow:button({ text = "Save", w = lvgl.PERCENT_SIZE + 16, press = function() + if Protocol.DEVICE_PARAM_LIST_complete then Protocol.sendParamStore() end + end }) + btnRow:button({ text = "Reload", w = lvgl.PERCENT_SIZE + 16, press = function() + Protocol.clearParams(); UI.invalidate() + end }) + btnRow:button({ text = "Bind", w = lvgl.PERCENT_SIZE + 16, press = function() + Protocol.sendBind() + end }) + btnRow:button({ text = "Tools", w = lvgl.PERCENT_SIZE + 16, press = function() + if Protocol.DEVICE_PARAM_LIST_complete then App.switchPage("tools") end + end }) end @@ -1298,6 +1297,217 @@ function App.switchPage(name) end +-- ============================================================================ +-- Mock data for EdgeTX Companion simulator +-- ============================================================================ + +local function setMock() + local _, rv = getVersion() + if string.sub(rv, -5) ~= "-simu" then return end + + -- Parameter set based on mLRS PARAMETERS.md documentation + local mockParams = { + -- Common parameters (shown on main page) + [0] = {typ=5, name="Bind Phrase", value="mlrs.1"}, + [1] = {typ=4, name="Mode", value=0, options="50 Hz,31 Hz,19 Hz", mask=7}, + [2] = {typ=4, name="RF Band", value=0, options="2.4 GHz,915 MHz FCC", mask=3}, + [3] = {typ=4, name="Ortho", value=0, options="off,1/3,2/3,3/3", mask=15}, + -- Tx parameters + [4] = {typ=4, name="Tx Power", value=2, options="10 mW,50 mW,100 mW", mask=7}, + [5] = {typ=4, name="Tx Diversity", value=0, options="enabled,antenna1,antenna2,r:en. t:ant1,r:en. t:ant2", mask=31}, + [6] = {typ=4, name="Tx Ch Source", value=1, options="none,crsf,in,mbridge", mask=15}, + [7] = {typ=4, name="Tx Ch Order", value=0, options="AETR,TAER,ETAR", mask=7}, + [8] = {typ=4, name="Tx In Mode", value=0, options="sbus,sbus inv", mask=3}, + [9] = {typ=4, name="Tx Ser Dest", value=0, options="serial,serial2,mbridge", mask=7}, + [10] = {typ=4, name="Tx Ser Baudrate", value=4, options="9600,19200,38400,57600,115200,230400", mask=63}, + [11] = {typ=4, name="Tx Snd RadioStat", value=0, options="off,1 Hz", mask=3}, + [12] = {typ=4, name="Tx Buzzer", value=0, options="off,LP,rxLQ", mask=7}, + -- Rx parameters + [13] = {typ=4, name="Rx Power", value=2, options="10 mW,50 mW,100 mW", mask=7}, + [14] = {typ=4, name="Rx Diversity", value=0, options="enabled,antenna1,antenna2,r:en. t:ant1,r:en. t:ant2", mask=31}, + [15] = {typ=4, name="Rx Ch Order", value=0, options="AETR,TAER,ETAR", mask=7}, + [16] = {typ=4, name="Rx Out Mode", value=0, options="sbus,crsf,sbus inv", mask=7}, + [17] = {typ=4, name="Rx FailSafe Mode", value=0, options="no sig,low thr,by cnf,low thr cnt,ch1ch4 cnt", mask=31}, + [18] = {typ=4, name="Rx Ser Baudrate", value=3, options="9600,19200,38400,57600,115200,230400", mask=63}, + [19] = {typ=4, name="Rx Ser Link Mode", value=0, options="transp.,mavlink,mavlinkX,mspX", mask=15}, + [20] = {typ=4, name="Rx Out Rssi Ch", value=0, options="off,5,6,7,8,9,10,11,12,13,14,15,16", mask=8191}, + [21] = {typ=4, name="Rx Out LQ Ch", value=0, options="off,5,6,7,8,9,10,11,12,13,14,15,16", mask=8191}, + [22] = {typ=4, name="Rx Buzzer", value=0, options="off,LP", mask=3}, + } + local MOCK_PARAM_COUNT = 23 + + -- Version v1.4.01: (1<<12)|(4<<6)|1 + local VER_U16 = 4353 + -- Setup layout v0.5.15: (0<<12)|(5<<6)|15 -> setuplayout_int = 515 + local LAYOUT_U16 = 335 + + local responseQueue = {} + + local function enqueue(cmd, payload) + responseQueue[#responseQueue + 1] = { + cmd = cmd, + len = MBRIDGE_CMD_LEN[cmd] or 24, + payload = payload, + } + end + + local function encStr(p, pos, str, maxLen) + for i = 0, maxLen - 1 do + if i < #str then + p[pos + i] = string.byte(str, i + 1) + else + p[pos + i] = 0 + end + end + end + + local function encU16(p, pos, val) + p[pos] = bit32.band(val, 0xFF) + p[pos + 1] = bit32.rshift(val, 8) + end + + local function buildDeviceItem(name) + local p = {} + encU16(p, 0, VER_U16) + encU16(p, 2, LAYOUT_U16) + encStr(p, 4, name, 20) + return p + end + + local function buildInfo() + local p = {} + encU16(p, 0, 65424) -- receiver_sensitivity = -112 dBm (as u16) + p[2] = 1 -- has_status=1, binding=0 + p[3] = 20 -- tx_power_dbm + p[4] = 14 -- rx_power_dbm + p[5] = 1 -- rx_available + p[6] = 42 -- tx_config_id + p[7] = 0 -- diversity flags + p[8] = MOCK_PARAM_COUNT + for i = 9, 23 do p[i] = 0 end + return p + end + + local function buildParamItem(idx) + local mp = mockParams[idx] + if not mp then return nil end + local p = {} + p[0] = idx + p[1] = mp.typ + encStr(p, 2, mp.name, 16) + if mp.typ == MBRIDGE_PARAM_TYPE.STR6 then + encStr(p, 18, mp.value, 6) + else + p[18] = (type(mp.value) == "number") and mp.value or 0 + for i = 19, 23 do p[i] = 0 end + end + return p + end + + local function buildParamItem2(idx) + local mp = mockParams[idx] + if not mp then return nil end + local p = {} + p[0] = idx + if mp.typ == MBRIDGE_PARAM_TYPE.LIST then + encU16(p, 1, mp.mask) + encStr(p, 3, mp.options, 21) + elseif mp.typ < MBRIDGE_PARAM_TYPE.LIST then + p[1] = mp.min or 0; p[2] = 0 + p[3] = mp.max or 0; p[4] = 0 + p[5] = 0; p[6] = 0 + encStr(p, 7, mp.unit or "", 6) + for i = 13, 23 do p[i] = 0 end + else -- STR6 + for i = 1, 23 do p[i] = 0 end + end + return p + end + + -- ITEM3: continuation bytes for LIST options > 20 chars + local function buildItem3(idx, opts) + local p = {} + p[0] = idx + for i = 0, 22 do + local si = 21 + i + if si < #opts then + p[1 + i] = string.byte(opts, si + 1) + else + p[1 + i] = 0 + end + end + return p + end + + -- ITEM4: continuation bytes for LIST options > 43 chars + local function buildItem4(idx, opts) + local p = {} + p[0] = idx + 128 + for i = 0, 22 do + local si = 44 + i + if si < #opts then + p[1 + i] = string.byte(opts, si + 1) + else + p[1 + i] = 0 + end + end + return p + end + + Protocol.isConnected = function() return true end + + Protocol.cmdPush = function(cmd, payload) + if cmd == MBRIDGE_CMD.REQUEST_INFO then + enqueue(MBRIDGE_CMD.DEVICE_ITEM_TX, buildDeviceItem("Mock Tx Module")) + enqueue(MBRIDGE_CMD.DEVICE_ITEM_RX, buildDeviceItem("Mock Rx Module")) + enqueue(MBRIDGE_CMD.INFO, buildInfo()) + elseif cmd == MBRIDGE_CMD.REQUEST_CMD then + local paramIdx = payload[2] + if mockParams[paramIdx] then + enqueue(MBRIDGE_CMD.PARAM_ITEM, buildParamItem(paramIdx)) + enqueue(MBRIDGE_CMD.PARAM_ITEM2, buildParamItem2(paramIdx)) + -- Queue ITEM3/ITEM4 for LIST options that overflow ITEM2's 21 bytes + local mp = mockParams[paramIdx] + if mp.typ == MBRIDGE_PARAM_TYPE.LIST and mp.options and #mp.options >= 21 then + enqueue(MBRIDGE_CMD.PARAM_ITEM3_4, buildItem3(paramIdx, mp.options)) + if #mp.options >= 44 then + enqueue(MBRIDGE_CMD.PARAM_ITEM3_4, buildItem4(paramIdx, mp.options)) + end + end + else + -- End-of-list marker + local eol = {}; eol[0] = 255 + for i = 1, 23 do eol[i] = 0 end + enqueue(MBRIDGE_CMD.PARAM_ITEM, eol) + end + elseif cmd == MBRIDGE_CMD.PARAM_SET then + local idx = payload[1] + if mockParams[idx] then + if mockParams[idx].typ == MBRIDGE_PARAM_TYPE.STR6 then + local s = "" + for i = 2, 7 do + if payload[i] and payload[i] > 0 then + s = s .. string.char(payload[i]) + end + end + mockParams[idx].value = s + else + mockParams[idx].value = payload[2] or 0 + end + end + end + -- PARAM_STORE, BIND_START, etc: silently accepted + end + + Protocol.cmdPop = function() + if #responseQueue > 0 then + return table.remove(responseQueue, 1) + end + return nil + end +end + + -- ============================================================================ -- LVGL support check fallback -- ============================================================================ @@ -1320,6 +1530,7 @@ local function init() end Protocol.setupBridge() + setMock() local tnow_10ms = getTime()