From 446f248dbaf9808c4c12567e19db2d453ae5d5cc Mon Sep 17 00:00:00 2001 From: gismo2004 Date: Sat, 30 May 2026 10:00:27 +0200 Subject: [PATCH 1/7] Refine RfTool widget --- src/WIDGETS/RfTool/app.lua | 361 +++++++++++++++++++++++++++++------- src/WIDGETS/RfTool/main.lua | 8 +- 2 files changed, 303 insertions(+), 66 deletions(-) diff --git a/src/WIDGETS/RfTool/app.lua b/src/WIDGETS/RfTool/app.lua index e2422dc..3e8e4a8 100644 --- a/src/WIDGETS/RfTool/app.lua +++ b/src/WIDGETS/RfTool/app.lua @@ -7,12 +7,65 @@ local w = { } local scriptsCompiled = assert(loadScript("/SCRIPTS/RF2/COMPILE/scripts_compiled.lua"))() -if scriptsCompiled then - w.state = "loading" -else - w.state = "compiling" +local initialWidgetState = scriptsCompiled and "loading" or "compiling" + +-- Longest possible state string +local STATE_MEASURE_TEXT = "Unknown Protocol" +local FONT_SIZES = { XXLSIZE, DBLSIZE, MIDSIZE, STDSIZE, SMLSIZE, TINSIZE } +-- Not available on older EdgeTX versions +local xlsize = _G["XLSIZE"] +if type(xlsize) == "number" then + table.insert(FONT_SIZES, 2, xlsize) +end + +local function measureFont(font_const, max_w, test_string) + local test_text = test_string or "X" + local text_w, text_h = lcd.sizeText(test_text, font_const) + + if max_w and text_w > max_w then return -1 end + + return text_h +end + +local function selectFittingFont(available_w, available_h, test_string, start_index) + start_index = start_index or 1 + for i = start_index, #FONT_SIZES do + local font_const = FONT_SIZES[i] + if font_const then + local font_h = measureFont(font_const, available_w, test_string) + -- Allow a bit of extra height + if font_h > 0 and font_h <= available_h + 2 then + return font_const, i + end + end + end + + -- Fallback to the smallest font if nothing fits + return FONT_SIZES[#FONT_SIZES], #FONT_SIZES end +local function getTelemetryText(options, measure) + local source = options.sourceName + if not source or source == "" then return "No source" end + + if measure then + if #source < 4 then + source = string.rep("W", 4 - #source) .. source + end + return source .. ": 0000" .. (options.Suffix or "") + end + + -- Not available at boot time + if not getValue then return source .. ":" end + + local value = getValue(source) + if value == nil then return source .. ": -" end + + return source .. ": " .. tostring(value) .. (options.Suffix or "") +end + +-- RF2 fullscreen pages call options:getText() on the active widget to append +-- widget-specific telemetry text to the subtitle w.options.getText = function(options) if not options.sourceName then return "" end if not getValue then return " - " .. options.sourceName .. ": " end @@ -20,18 +73,33 @@ w.options.getText = function(options) end local compileTask = nil -local uiTask = nil -local backgroundTask = nil local timeCreated = getTime() -local rfWidgets = {} +-- RF2 is shared across all RfTool instances on a page, so RfTool keeps one +-- shared store under rf2 for cross-instance state and subscribers. +local function getRfToolShared() + rf2.rfToolShared = rf2.rfToolShared or {} + rf2.rfToolShared.registeredWidgets = rf2.rfToolShared.registeredWidgets or {} + return rf2.rfToolShared +end + local function registerWidget(widget) - table.insert(rfWidgets, widget) + -- Subscriber list for widgets that want RfTool onStateChanged callbacks + local registeredWidgets = getRfToolShared().registeredWidgets + for i = 1, #registeredWidgets do + if registeredWidgets[i] == widget then + return + end + end + + registeredWidgets[#registeredWidgets + 1] = widget end local function publishStateChangedEvent(newState) - for k, v in pairs(rfWidgets) do + -- Only explicit subscribers are notified here. Other RfTool instances see + -- the shared widgetState change in their own refresh/background cycle. + for k, v in pairs(getRfToolShared().registeredWidgets) do if v.onStateChanged then rf2.call(v.onStateChanged, v, newState) end @@ -39,8 +107,12 @@ local function publishStateChangedEvent(newState) end local previousArmState = 0 + +-- Multiple RfTool instances can exist on one EdgeTX page, but the RF2 runtime is shared. +-- widgetState in the shared store is the single source of truth. Each widget only keeps +-- local redraw bookkeeping such as needsRedraw and renderedState. local function setArmState(widget) - if not getValue then return end -- not available at boot time + if not getValue then return end -- Not available at boot time local armState = getValue("ARM") --[NIR -- Use ANT instead of ARM in the simulator @@ -48,20 +120,28 @@ local function setArmState(widget) --]] if armState ~= previousArmState then previousArmState = armState - local state = bit32.btest(armState, 1) and "armed" or "disarmed" + -- bit32 is marked as deprecated, so switch to native bit operators + local state = (armState & 1) ~= 0 and "armed" or "disarmed" widget:setState(state) end end w.setState = function(self, state) - -- This function will also be called from the background task - if self.state == state then return end - self.state = state + -- This function can also be called from the background task + -- Multiple RfTool instances can reach the same transition. Only the first + -- one that changes the shared state should notify subscribers + if getRfToolShared().widgetState == state then return end + + getRfToolShared().widgetState = state + -- Any logical state change can affect the rendered widget text on the + -- normal screen. Mark that surface dirty and let refresh() rebuild it + -- when widget mode is active. + self.needsRedraw = true if state == "disconnected" then rf2.modelName = nil previousArmState = 0 end - publishStateChangedEvent(self.state) + publishStateChangedEvent(state) end local function initializeRf2GlobalVar() @@ -72,14 +152,20 @@ local function initializeRf2GlobalVar() end local function loadScripts(widget) - -- load required scripts - rf2.radio = rf2.executeScript("radios") - rf2.mspQueue = rf2.executeScript("MSP/mspQueue") - rf2.mspHelper = rf2.executeScript("MSP/mspHelper") + -- Load required scripts + rf2.radio = rf2.radio or rf2.executeScript("radios") + rf2.mspQueue = rf2.mspQueue or rf2.executeScript("MSP/mspQueue") + rf2.mspHelper = rf2.mspHelper or rf2.executeScript("MSP/mspHelper") - -- load tasks - uiTask = rf2.executeScript("ui_lvgl_runner") - backgroundTask = rf2.executeScript("background") + -- uiTask/backgroundTask are RF2 singletons. Sharing them on rf2 avoids + -- each widget instance creating its own runner with conflicting + -- fullscreen state. + rf2.uiTask = rf2.uiTask or rf2.executeScript("ui_lvgl_runner") + rf2.backgroundTask = rf2.backgroundTask or rf2.executeScript("background") + -- rf2.widget is the active widget context consumed by RF2 helpers that + -- need widget-specific data such as subtitle text. It is not a broadcast + -- to all widgets. + rf2.widget = widget end local function getModelName() @@ -92,30 +178,147 @@ local function getModelName() return modelName or "Unknown" end +local function getStateText() + local state = getRfToolShared().widgetState + return string.upper(string.sub(state, 1, 1)) .. string.sub(state, 2) +end + +local function getDisplayedModelName(widget) + if widget.options.HideModel == 1 then return nil end + return getModelName() +end + local function showWidget(widget) - lvgl.clear(); - lvgl.build({ - { - type = "box", flexFlow = lvgl.FLOW_COLUMN, children = - { - { type = "label", text = function() return getModelName() end, w = widget.zone.x, font = DBLSIZE, align = CENTER }, - { - type = "label", - text = function() - return string.upper(string.sub(widget.state, 1, 1)) - .. string.sub(widget.state, 2) - .. widget.options:getText() - end, - w = widget.zone.x, - align = CENTER - }, + local widget_w = widget.zone.w or widget.zone.x + local widget_h = widget.zone.h or widget.zone.y + local displayed_model_name = getDisplayedModelName(widget) + local show_model = displayed_model_name ~= nil + local show_state = widget.options.HideState ~= 1 + local show_telemetry = widget.options.HideTelemetry ~= 1 + local telemetry_measure = show_telemetry and getTelemetryText(widget.options, true) or nil + local row1_text = nil + local row1_measure = nil + local row2_text = nil + local row2_measure = nil + + -- Determine what text to show in row 1 and row 2 based on the options + if show_model then + row1_text = displayed_model_name + row1_measure = displayed_model_name + end + + if show_state and show_telemetry then + if row1_text then + row2_text = function() + return getStateText() .. " - " .. getTelemetryText(widget.options) + end + row2_measure = STATE_MEASURE_TEXT .. " - " .. telemetry_measure + else + row1_text = function() return getStateText() end + row1_measure = STATE_MEASURE_TEXT + row2_text = function() return getTelemetryText(widget.options) end + row2_measure = telemetry_measure + end + elseif show_state then + if row1_text then + row2_text = function() return getStateText() end + row2_measure = STATE_MEASURE_TEXT + else + row1_text = function() return getStateText() end + row1_measure = STATE_MEASURE_TEXT + end + elseif show_telemetry then + if row1_text then + row2_text = function() return getTelemetryText(widget.options) end + row2_measure = telemetry_measure + else + row1_text = function() return getTelemetryText(widget.options) end + row1_measure = telemetry_measure + end + end + + local children = {} + + if row1_text then + -- Build the lvgl object tree. If no rows are enabled we leave + -- children empty, which clears the widget while still flowing through + -- the same render tail. + local pad_x = 2 + local pad_y = 2 + local row_gap = 1 + local content_w = math.max(1, widget_w - 2 * pad_x) + local content_h = math.max(1, widget_h - 2 * pad_y) + local text_color = widget.options.TextColor or COLOR_THEME_PRIMARY1 + + if row2_text then + local top_h = math.max(1, math.floor((content_h - row_gap + 1) / 2)) + local top_font, top_index = selectFittingFont(content_w, top_h, row1_measure) + local top_font_h = measureFont(top_font) + local detail_h = math.max(1, content_h - top_font_h - row_gap) + -- The second font should be smaller than the first one, so start + -- looking from the next smaller font than the one used for row 1 + local detail_font = selectFittingFont(content_w, detail_h, row2_measure, math.min(top_index + 1, #FONT_SIZES)) + local detail_font_h = measureFont(detail_font) + local detail_y = pad_y + top_font_h + row_gap + + children[#children + 1] = { + type = "label", + x = pad_x, + y = pad_y, + w = content_w, + h = top_font_h, + text = row1_text, + font = top_font, + color = text_color, + align = LEFT + } + children[#children + 1] = { + type = "label", + x = pad_x, + y = detail_y, + w = content_w, + h = detail_font_h, + text = row2_text, + font = detail_font, + color = text_color, + align = LEFT + } + else + local row_font = selectFittingFont(content_w, content_h, row1_measure) + local row_font_h = measureFont(row_font) + local row_y = pad_y + math.max(0, math.floor((content_h - row_font_h) / 2)) + + children[#children + 1] = { + type = "label", + x = pad_x, + y = row_y, + w = content_w, + h = row_font_h, + text = row1_text, + font = row_font, + color = text_color, + align = LEFT } - } - }); + end + end + lvgl.clear() + lvgl.build(children) + + widget.renderedState = getRfToolShared().widgetState + widget.renderedModelName = displayed_model_name widget.visible = true end +local function shouldRedrawWidget(widget) + local displayed_model_name = getDisplayedModelName(widget) + + return widget.needsRedraw + or not widget.visible + or widget.renderedState ~= getRfToolShared().widgetState + or widget.renderedModelName ~= displayed_model_name +end + w.update = function(widget, options) widget.options = options if options and options.Source and getFieldInfo then @@ -124,34 +327,50 @@ w.update = function(widget, options) widget.options.sourceName = fieldInfo.name end end + widget.needsRedraw = true if lvgl.isFullScreen() or lvgl.isAppMode() then - rf2.restartUi() + if not widget.pendingRf2UiRestart then + -- Clear the stale normal widget before the fullscreen handoff, + -- otherwise the previous widget tree can flash briefly before RF2 + -- rebuilds its UI. + lvgl.clear() + lvgl.build({}) + widget.visible = false + end + -- update() is the first point where the fullscreen transition is + -- visible, so it clears the stale widget tree early and stores a + -- one-shot handoff token. refresh() consumes that token and performs + -- the actual RF2 restart in one place. + widget.pendingRf2UiRestart = true else - showWidget(widget) + widget.pendingRf2UiRestart = false end end w.background = function(widget, calledFromRefresh) - if widget.state == "compiling" then + local state = getRfToolShared().widgetState + + if state == "compiling" then compileTask = compileTask or assert(loadScript("/SCRIPTS/RF2/COMPILE/compile.lua"))() if compileTask() == 1 then compileTask = nil - widget.state = "loading" + widget:setState("loading") end return - elseif widget.state == "loading" + elseif state == "loading" and (getTime() - timeCreated) / 100 > 1 -- bootgrace timeout then if not rf2.widget then + -- First initialized RfTool instance provides the initial widget context for RF2. rf2.widget = widget end - widget.state = "unknown protocol" - elseif widget.state == "unknown protocol" then + widget:setState("unknown protocol") + elseif state == "unknown protocol" then local protocol = rf2.executeScript("F/getProtocol")() if protocol then loadScripts(widget) - widget.state = "ready" + widget:setState("ready") end end @@ -159,33 +378,44 @@ w.background = function(widget, calledFromRefresh) if not calledFromRefresh then widget.visible = false - if uiTask then - -- uiTask also handles mspQueue in the background, so make sure to call it - -- even when the widget isn't visible. - uiTask() + if rf2.uiTask then + -- uiTask also handles mspQueue in the background, so make sure to + -- call it even when the widget isn't visible. + rf2.uiTask(nil, nil, true) end end - if backgroundTask then - backgroundTask(widget) + if rf2.backgroundTask then + rf2.backgroundTask(widget) end end -local redrawWidget = false w.refresh = function(widget, event, touchState) - if uiTask ~= nil then - if redrawWidget or not widget.visible then - -- If we immediately show the widget after lvgl.exitFullScreen(), the widget if briefly - -- displayed in full screen mode. Using redrawWidget prevents that. - showWidget(widget) - redrawWidget = false - end + local isWidgetMode = not(lvgl.isFullScreen() or lvgl.isAppMode()) + + if not isWidgetMode and rf2 then + -- Fullscreen RF2 pages should use the widget that actually triggered + -- the handoff. + rf2.widget = widget + end + + if not isWidgetMode and widget.pendingRf2UiRestart then + widget.pendingRf2UiRestart = false + rf2.restartUi() + end + + -- needsRedraw is only for the normal widget surface. Fullscreen/app mode + -- is driven by RF2. + if isWidgetMode and shouldRedrawWidget(widget) then + showWidget(widget) + widget.needsRedraw = false + end - local noUi = not(lvgl.isFullScreen() or lvgl.isAppMode()) - local result = uiTask(event, touchState, noUi) + if rf2.uiTask ~= nil then + local result = rf2.uiTask(event, touchState, isWidgetMode) if lvgl.isFullScreen() and result == 2 then lvgl.exitFullScreen() - redrawWidget = true + widget.needsRedraw = true end end @@ -193,6 +423,7 @@ w.refresh = function(widget, event, touchState) end initializeRf2GlobalVar() +getRfToolShared().widgetState = getRfToolShared().widgetState or initialWidgetState rf2.registerWidget = registerWidget rf2.rfToolApiVersion = 1.00 diff --git a/src/WIDGETS/RfTool/main.lua b/src/WIDGETS/RfTool/main.lua index 818b8ff..c4567e0 100644 --- a/src/WIDGETS/RfTool/main.lua +++ b/src/WIDGETS/RfTool/main.lua @@ -2,9 +2,14 @@ -- Even if a widget isn't used by a particular model. local name = "RF Tool" +---@type WidgetOptions local options = { { "Source", SOURCE, "Vcel" }, - { "Suffix", STRING, "" } + { "Suffix", STRING, "" }, + { "HideModel", BOOL, 0}, + { "HideState", BOOL, 0 }, + { "HideTelemetry", BOOL, 0 }, + { "TextColor", COLOR, COLOR_THEME_PRIMARY1 } } if lvgl == nil then @@ -17,6 +22,7 @@ if lvgl == nil then end, } end +---@type WidgetScript local function create(zone, options) local widget = loadScript("/WIDGETS/RfTool/app.lua")(zone, options) From 2c29c705d8b7b7ecf094f2af3f66de4dce24df6e Mon Sep 17 00:00:00 2001 From: gismo2004 Date: Sat, 30 May 2026 10:19:16 +0200 Subject: [PATCH 2/7] some more text to read --- src/WIDGETS/RfTool/main.lua | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/WIDGETS/RfTool/main.lua b/src/WIDGETS/RfTool/main.lua index c4567e0..bd21b26 100644 --- a/src/WIDGETS/RfTool/main.lua +++ b/src/WIDGETS/RfTool/main.lua @@ -2,6 +2,9 @@ -- Even if a widget isn't used by a particular model. local name = "RF Tool" +--- this is for VSCode extnension 'EdgeTX Dev Kit' +---@type WidgetScript + ---@type WidgetOptions local options = { { "Source", SOURCE, "Vcel" }, @@ -11,6 +14,8 @@ local options = { { "HideTelemetry", BOOL, 0 }, { "TextColor", COLOR, COLOR_THEME_PRIMARY1 } } +-- newly added options in EdgeTX don't get their set default value, +-- so the color is black by default when "updating" the widget. if lvgl == nil then return { @@ -22,7 +27,6 @@ if lvgl == nil then end, } end ----@type WidgetScript local function create(zone, options) local widget = loadScript("/WIDGETS/RfTool/app.lua")(zone, options) From 2166fb64433c6516634abef304eae217b368783d Mon Sep 17 00:00:00 2001 From: gismo2004 Date: Sat, 30 May 2026 11:10:55 +0200 Subject: [PATCH 3/7] fix MK3 hanging in "wait for API" when switching to fullscreen --- src/WIDGETS/RfTool/app.lua | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/WIDGETS/RfTool/app.lua b/src/WIDGETS/RfTool/app.lua index 3e8e4a8..c77ad0b 100644 --- a/src/WIDGETS/RfTool/app.lua +++ b/src/WIDGETS/RfTool/app.lua @@ -152,6 +152,8 @@ local function initializeRf2GlobalVar() end local function loadScripts(widget) + local shared = getRfToolShared() + -- Load required scripts rf2.radio = rf2.radio or rf2.executeScript("radios") rf2.mspQueue = rf2.mspQueue or rf2.executeScript("MSP/mspQueue") @@ -165,6 +167,7 @@ local function loadScripts(widget) -- rf2.widget is the active widget context consumed by RF2 helpers that -- need widget-specific data such as subtitle text. It is not a broadcast -- to all widgets. + shared.runtimeWidget = shared.runtimeWidget or widget rf2.widget = widget end @@ -328,9 +331,15 @@ w.update = function(widget, options) end end widget.needsRedraw = true + local shared = getRfToolShared() if lvgl.isFullScreen() or lvgl.isAppMode() then - if not widget.pendingRf2UiRestart then + if shared.fullscreenWidget == nil then + shared.fullscreenWidget = widget + shared.runtimeWidget = widget + end + + if shared.fullscreenWidget == widget and not widget.pendingRf2UiRestart then -- Clear the stale normal widget before the fullscreen handoff, -- otherwise the previous widget tree can flash briefly before RF2 -- rebuilds its UI. @@ -342,14 +351,18 @@ w.update = function(widget, options) -- visible, so it clears the stale widget tree early and stores a -- one-shot handoff token. refresh() consumes that token and performs -- the actual RF2 restart in one place. - widget.pendingRf2UiRestart = true + widget.pendingRf2UiRestart = shared.fullscreenWidget == widget else widget.pendingRf2UiRestart = false + if shared.fullscreenWidget == widget then + shared.fullscreenWidget = nil + end end end w.background = function(widget, calledFromRefresh) - local state = getRfToolShared().widgetState + local shared = getRfToolShared() + local state = shared.widgetState if state == "compiling" then compileTask = compileTask or assert(loadScript("/SCRIPTS/RF2/COMPILE/compile.lua"))() @@ -378,25 +391,26 @@ w.background = function(widget, calledFromRefresh) if not calledFromRefresh then widget.visible = false - if rf2.uiTask then + if shared.runtimeWidget == widget and rf2.uiTask then -- uiTask also handles mspQueue in the background, so make sure to -- call it even when the widget isn't visible. rf2.uiTask(nil, nil, true) end end - if rf2.backgroundTask then + if shared.runtimeWidget == widget and rf2.backgroundTask then rf2.backgroundTask(widget) end end w.refresh = function(widget, event, touchState) local isWidgetMode = not(lvgl.isFullScreen() or lvgl.isAppMode()) + local shared = getRfToolShared() - if not isWidgetMode and rf2 then + if not isWidgetMode and rf2 and shared.runtimeWidget == widget then -- Fullscreen RF2 pages should use the widget that actually triggered -- the handoff. - rf2.widget = widget + rf2.widget = shared.fullscreenWidget or widget end if not isWidgetMode and widget.pendingRf2UiRestart then @@ -411,10 +425,11 @@ w.refresh = function(widget, event, touchState) widget.needsRedraw = false end - if rf2.uiTask ~= nil then + if shared.runtimeWidget == widget and rf2.uiTask ~= nil then local result = rf2.uiTask(event, touchState, isWidgetMode) if lvgl.isFullScreen() and result == 2 then lvgl.exitFullScreen() + shared.fullscreenWidget = nil widget.needsRedraw = true end end From a1bee10febe413c9df54e14144282768278ef1c8 Mon Sep 17 00:00:00 2001 From: gismo2004 Date: Sun, 31 May 2026 08:28:31 +0200 Subject: [PATCH 4/7] Revert all the multi instance code --- src/WIDGETS/RfTool/app.lua | 192 +++++++++++-------------------------- 1 file changed, 55 insertions(+), 137 deletions(-) diff --git a/src/WIDGETS/RfTool/app.lua b/src/WIDGETS/RfTool/app.lua index c77ad0b..1909ec2 100644 --- a/src/WIDGETS/RfTool/app.lua +++ b/src/WIDGETS/RfTool/app.lua @@ -7,7 +7,12 @@ local w = { } local scriptsCompiled = assert(loadScript("/SCRIPTS/RF2/COMPILE/scripts_compiled.lua"))() -local initialWidgetState = scriptsCompiled and "loading" or "compiling" +if scriptsCompiled then + w.state = "loading" +else + w.state = "compiling" +end + -- Longest possible state string local STATE_MEASURE_TEXT = "Unknown Protocol" @@ -64,8 +69,6 @@ local function getTelemetryText(options, measure) return source .. ": " .. tostring(value) .. (options.Suffix or "") end --- RF2 fullscreen pages call options:getText() on the active widget to append --- widget-specific telemetry text to the subtitle w.options.getText = function(options) if not options.sourceName then return "" end if not getValue then return " - " .. options.sourceName .. ": " end @@ -73,33 +76,18 @@ w.options.getText = function(options) end local compileTask = nil +local uiTask = nil +local backgroundTask = nil local timeCreated = getTime() --- RF2 is shared across all RfTool instances on a page, so RfTool keeps one --- shared store under rf2 for cross-instance state and subscribers. -local function getRfToolShared() - rf2.rfToolShared = rf2.rfToolShared or {} - rf2.rfToolShared.registeredWidgets = rf2.rfToolShared.registeredWidgets or {} - return rf2.rfToolShared -end - +local rfWidgets = {} local function registerWidget(widget) - -- Subscriber list for widgets that want RfTool onStateChanged callbacks - local registeredWidgets = getRfToolShared().registeredWidgets - for i = 1, #registeredWidgets do - if registeredWidgets[i] == widget then - return - end - end - - registeredWidgets[#registeredWidgets + 1] = widget + table.insert(rfWidgets, widget) end local function publishStateChangedEvent(newState) - -- Only explicit subscribers are notified here. Other RfTool instances see - -- the shared widgetState change in their own refresh/background cycle. - for k, v in pairs(getRfToolShared().registeredWidgets) do + for k, v in pairs(rfWidgets) do if v.onStateChanged then rf2.call(v.onStateChanged, v, newState) end @@ -107,12 +95,8 @@ local function publishStateChangedEvent(newState) end local previousArmState = 0 - --- Multiple RfTool instances can exist on one EdgeTX page, but the RF2 runtime is shared. --- widgetState in the shared store is the single source of truth. Each widget only keeps --- local redraw bookkeeping such as needsRedraw and renderedState. local function setArmState(widget) - if not getValue then return end -- Not available at boot time + if not getValue then return end -- not available at boot time local armState = getValue("ARM") --[NIR -- Use ANT instead of ARM in the simulator @@ -127,21 +111,14 @@ local function setArmState(widget) end w.setState = function(self, state) - -- This function can also be called from the background task - -- Multiple RfTool instances can reach the same transition. Only the first - -- one that changes the shared state should notify subscribers - if getRfToolShared().widgetState == state then return end - - getRfToolShared().widgetState = state - -- Any logical state change can affect the rendered widget text on the - -- normal screen. Mark that surface dirty and let refresh() rebuild it - -- when widget mode is active. - self.needsRedraw = true + -- This function will also be called from the background task + if self.state == state then return end + self.state = state if state == "disconnected" then rf2.modelName = nil previousArmState = 0 end - publishStateChangedEvent(state) + publishStateChangedEvent(self.state) end local function initializeRf2GlobalVar() @@ -152,23 +129,14 @@ local function initializeRf2GlobalVar() end local function loadScripts(widget) - local shared = getRfToolShared() - - -- Load required scripts - rf2.radio = rf2.radio or rf2.executeScript("radios") - rf2.mspQueue = rf2.mspQueue or rf2.executeScript("MSP/mspQueue") - rf2.mspHelper = rf2.mspHelper or rf2.executeScript("MSP/mspHelper") - - -- uiTask/backgroundTask are RF2 singletons. Sharing them on rf2 avoids - -- each widget instance creating its own runner with conflicting - -- fullscreen state. - rf2.uiTask = rf2.uiTask or rf2.executeScript("ui_lvgl_runner") - rf2.backgroundTask = rf2.backgroundTask or rf2.executeScript("background") - -- rf2.widget is the active widget context consumed by RF2 helpers that - -- need widget-specific data such as subtitle text. It is not a broadcast - -- to all widgets. - shared.runtimeWidget = shared.runtimeWidget or widget - rf2.widget = widget + -- load required scripts + rf2.radio = rf2.executeScript("radios") + rf2.mspQueue = rf2.executeScript("MSP/mspQueue") + rf2.mspHelper = rf2.executeScript("MSP/mspHelper") + + -- load tasks + uiTask = rf2.executeScript("ui_lvgl_runner") + backgroundTask = rf2.executeScript("background") end local function getModelName() @@ -181,8 +149,8 @@ local function getModelName() return modelName or "Unknown" end -local function getStateText() - local state = getRfToolShared().widgetState +local function getStateText(widget) + local state = widget.state return string.upper(string.sub(state, 1, 1)) .. string.sub(state, 2) end @@ -213,21 +181,21 @@ local function showWidget(widget) if show_state and show_telemetry then if row1_text then row2_text = function() - return getStateText() .. " - " .. getTelemetryText(widget.options) + return getStateText(widget) .. " - " .. getTelemetryText(widget.options) end row2_measure = STATE_MEASURE_TEXT .. " - " .. telemetry_measure else - row1_text = function() return getStateText() end + row1_text = function() return getStateText(widget) end row1_measure = STATE_MEASURE_TEXT row2_text = function() return getTelemetryText(widget.options) end row2_measure = telemetry_measure end elseif show_state then if row1_text then - row2_text = function() return getStateText() end + row2_text = function() return getStateText(widget) end row2_measure = STATE_MEASURE_TEXT else - row1_text = function() return getStateText() end + row1_text = function() return getStateText(widget) end row1_measure = STATE_MEASURE_TEXT end elseif show_telemetry then @@ -308,20 +276,10 @@ local function showWidget(widget) lvgl.clear() lvgl.build(children) - widget.renderedState = getRfToolShared().widgetState widget.renderedModelName = displayed_model_name widget.visible = true end -local function shouldRedrawWidget(widget) - local displayed_model_name = getDisplayedModelName(widget) - - return widget.needsRedraw - or not widget.visible - or widget.renderedState ~= getRfToolShared().widgetState - or widget.renderedModelName ~= displayed_model_name -end - w.update = function(widget, options) widget.options = options if options and options.Source and getFieldInfo then @@ -330,60 +288,34 @@ w.update = function(widget, options) widget.options.sourceName = fieldInfo.name end end - widget.needsRedraw = true - local shared = getRfToolShared() if lvgl.isFullScreen() or lvgl.isAppMode() then - if shared.fullscreenWidget == nil then - shared.fullscreenWidget = widget - shared.runtimeWidget = widget - end - - if shared.fullscreenWidget == widget and not widget.pendingRf2UiRestart then - -- Clear the stale normal widget before the fullscreen handoff, - -- otherwise the previous widget tree can flash briefly before RF2 - -- rebuilds its UI. - lvgl.clear() - lvgl.build({}) - widget.visible = false - end - -- update() is the first point where the fullscreen transition is - -- visible, so it clears the stale widget tree early and stores a - -- one-shot handoff token. refresh() consumes that token and performs - -- the actual RF2 restart in one place. - widget.pendingRf2UiRestart = shared.fullscreenWidget == widget + rf2.restartUi() else - widget.pendingRf2UiRestart = false - if shared.fullscreenWidget == widget then - shared.fullscreenWidget = nil - end + showWidget(widget) end end w.background = function(widget, calledFromRefresh) - local shared = getRfToolShared() - local state = shared.widgetState - - if state == "compiling" then + if widget.state == "compiling" then compileTask = compileTask or assert(loadScript("/SCRIPTS/RF2/COMPILE/compile.lua"))() if compileTask() == 1 then compileTask = nil - widget:setState("loading") + widget.state = "loading" end return - elseif state == "loading" + elseif widget.state == "loading" and (getTime() - timeCreated) / 100 > 1 -- bootgrace timeout then if not rf2.widget then - -- First initialized RfTool instance provides the initial widget context for RF2. rf2.widget = widget end - widget:setState("unknown protocol") - elseif state == "unknown protocol" then + widget.state = "unknown protocol" + elseif widget.state == "unknown protocol" then local protocol = rf2.executeScript("F/getProtocol")() if protocol then loadScripts(widget) - widget:setState("ready") + widget.state = "ready" end end @@ -391,46 +323,33 @@ w.background = function(widget, calledFromRefresh) if not calledFromRefresh then widget.visible = false - if shared.runtimeWidget == widget and rf2.uiTask then - -- uiTask also handles mspQueue in the background, so make sure to - -- call it even when the widget isn't visible. - rf2.uiTask(nil, nil, true) + if uiTask then + -- uiTask also handles mspQueue in the background, so make sure to call it + -- even when the widget isn't visible. + uiTask() end end - if shared.runtimeWidget == widget and rf2.backgroundTask then - rf2.backgroundTask(widget) + if backgroundTask then + backgroundTask(widget) end end +local redrawWidget = false w.refresh = function(widget, event, touchState) - local isWidgetMode = not(lvgl.isFullScreen() or lvgl.isAppMode()) - local shared = getRfToolShared() - - if not isWidgetMode and rf2 and shared.runtimeWidget == widget then - -- Fullscreen RF2 pages should use the widget that actually triggered - -- the handoff. - rf2.widget = shared.fullscreenWidget or widget - end - - if not isWidgetMode and widget.pendingRf2UiRestart then - widget.pendingRf2UiRestart = false - rf2.restartUi() - end - - -- needsRedraw is only for the normal widget surface. Fullscreen/app mode - -- is driven by RF2. - if isWidgetMode and shouldRedrawWidget(widget) then - showWidget(widget) - widget.needsRedraw = false - end + if uiTask ~= nil then + if redrawWidget or not widget.visible or widget.renderedModelName ~= getDisplayedModelName(widget) then + -- If we immediately show the widget after lvgl.exitFullScreen(), the widget if briefly + -- displayed in full screen mode. Using redrawWidget prevents that. + showWidget(widget) + redrawWidget = false + end - if shared.runtimeWidget == widget and rf2.uiTask ~= nil then - local result = rf2.uiTask(event, touchState, isWidgetMode) + local noUi = not(lvgl.isFullScreen() or lvgl.isAppMode()) + local result = uiTask(event, touchState, noUi) if lvgl.isFullScreen() and result == 2 then lvgl.exitFullScreen() - shared.fullscreenWidget = nil - widget.needsRedraw = true + redrawWidget = true end end @@ -438,7 +357,6 @@ w.refresh = function(widget, event, touchState) end initializeRf2GlobalVar() -getRfToolShared().widgetState = getRfToolShared().widgetState or initialWidgetState rf2.registerWidget = registerWidget rf2.rfToolApiVersion = 1.00 From 5db5547bd24ead78f5867192df22444b4419526c Mon Sep 17 00:00:00 2001 From: gismo2004 Date: Sun, 31 May 2026 10:04:14 +0200 Subject: [PATCH 5/7] pull out the font selection logic into a seperate helper script --- src/SCRIPTS/RF2/COMPILE/scripts.lua | 1 + src/SCRIPTS/RF2/F/fontTools.lua | 93 +++++++++++++++++++++++++++++ src/WIDGETS/RfTool/app.lua | 49 +++------------ 3 files changed, 102 insertions(+), 41 deletions(-) create mode 100644 src/SCRIPTS/RF2/F/fontTools.lua diff --git a/src/SCRIPTS/RF2/COMPILE/scripts.lua b/src/SCRIPTS/RF2/COMPILE/scripts.lua index c6b734d..e9c337a 100644 --- a/src/SCRIPTS/RF2/COMPILE/scripts.lua +++ b/src/SCRIPTS/RF2/COMPILE/scripts.lua @@ -9,6 +9,7 @@ local scripts = { "/SCRIPTS/RF2/LCD/shared.lua", "/SCRIPTS/RF2/LCD/waitMessage.lua", "/SCRIPTS/RF2/F/canUseLvgl.lua", + "/SCRIPTS/RF2/F/fontTools.lua", "/SCRIPTS/RF2/F/formatSeconds.lua", "/SCRIPTS/RF2/F/getBit.lua", "/SCRIPTS/RF2/F/getLvglSubtitle.lua", diff --git a/src/SCRIPTS/RF2/F/fontTools.lua b/src/SCRIPTS/RF2/F/fontTools.lua new file mode 100644 index 0000000..016d850 --- /dev/null +++ b/src/SCRIPTS/RF2/F/fontTools.lua @@ -0,0 +1,93 @@ +-- Usage: local fontTools = rf2.executeScript("F/fontTools")() + +local fontSizes = { XXLSIZE, DBLSIZE, MIDSIZE, STDSIZE, SMLSIZE, TINSIZE } +local fontNames = { "XXLSIZE", "DBLSIZE", "MIDSIZE", "STDSIZE", "SMLSIZE", "TINSIZE" } + +local xlSize = _G["XLSIZE"] +if type(xlSize) == "number" then + table.insert(fontSizes, 2, xlSize) + table.insert(fontNames, 2, "XLSIZE") +end + +-- Return the measured font height, or -1 when the test string exceeds maxW +local function measureFont(fontConst, maxW, testString) + local testText = testString or "X" + local textW, textH = lcd.sizeText(testText, fontConst) + + if maxW and textW > maxW then return -1 end + + return textH +end + +local function getFontIndex(fontConst) + for i = 1, #fontSizes do + if fontSizes[i] == fontConst then return i end + end + + return nil +end + +-- Pick the largest fitting font, optionally limited to fonts smaller than +-- smallerThanFont +-- heightTolerance keeps the height fit check slightly forgiving because rendered text +-- can end up a couple of pixels taller than a strict sizeText limit suggests +-- If nothing fits, return the smallest available font anyway so callers do +-- not need a separate nil fallback path +local function selectFont(availableH, availableW, testString, smallerThanFont, heightTolerance) + local maxH = availableH + (heightTolerance or 2) + local startIndex = 1 + + if smallerThanFont then + local maxIndex = getFontIndex(smallerThanFont) + if maxIndex then startIndex = math.min(maxIndex + 1, #fontSizes) end + end + + for i = startIndex, #fontSizes do + local fontConst = fontSizes[i] + if fontConst then + local fontH = measureFont(fontConst, availableW, testString) + if fontH > 0 and fontH <= maxH then return fontConst end + end + end + + return fontSizes[#fontSizes] +end + +-- Return the smallest font from a list of already selected font constants +-- Pass the font values returned by selectFont(...); because fontSizes is +-- ordered from largest to smallest, this just keeps the highest matching index +local function pickSmallestFont(...) + local selectedFont = nil + local selectedIndex = nil + + for i = 1, select("#", ...) do + local fontConst = select(i, ...) + if fontConst then + for j = 1, #fontSizes do + if fontSizes[j] == fontConst and (not selectedIndex or j > selectedIndex) then + selectedFont = fontConst + selectedIndex = j + break + end + end + end + end + + return selectedFont or STDSIZE +end + +-- Return the symbolic EdgeTX font name for a font constant when needed +-- for e.g. debug output +local function getFontName(fontConst) + local index = getFontIndex(fontConst) + if index then return fontNames[index] end + + return nil +end + +return { + measureFont = measureFont, + selectFont = selectFont, + pickSmallestFont = pickSmallestFont, + getFontName = getFontName +} diff --git a/src/WIDGETS/RfTool/app.lua b/src/WIDGETS/RfTool/app.lua index 1909ec2..f5cdee7 100644 --- a/src/WIDGETS/RfTool/app.lua +++ b/src/WIDGETS/RfTool/app.lua @@ -6,6 +6,8 @@ local w = { options = options } +local font_tools = assert(loadScript("/SCRIPTS/RF2/F/fontTools.lua"))() + local scriptsCompiled = assert(loadScript("/SCRIPTS/RF2/COMPILE/scripts_compiled.lua"))() if scriptsCompiled then w.state = "loading" @@ -13,41 +15,8 @@ else w.state = "compiling" end - -- Longest possible state string local STATE_MEASURE_TEXT = "Unknown Protocol" -local FONT_SIZES = { XXLSIZE, DBLSIZE, MIDSIZE, STDSIZE, SMLSIZE, TINSIZE } --- Not available on older EdgeTX versions -local xlsize = _G["XLSIZE"] -if type(xlsize) == "number" then - table.insert(FONT_SIZES, 2, xlsize) -end - -local function measureFont(font_const, max_w, test_string) - local test_text = test_string or "X" - local text_w, text_h = lcd.sizeText(test_text, font_const) - - if max_w and text_w > max_w then return -1 end - - return text_h -end - -local function selectFittingFont(available_w, available_h, test_string, start_index) - start_index = start_index or 1 - for i = start_index, #FONT_SIZES do - local font_const = FONT_SIZES[i] - if font_const then - local font_h = measureFont(font_const, available_w, test_string) - -- Allow a bit of extra height - if font_h > 0 and font_h <= available_h + 2 then - return font_const, i - end - end - end - - -- Fallback to the smallest font if nothing fits - return FONT_SIZES[#FONT_SIZES], #FONT_SIZES -end local function getTelemetryText(options, measure) local source = options.sourceName @@ -223,13 +192,11 @@ local function showWidget(widget) if row2_text then local top_h = math.max(1, math.floor((content_h - row_gap + 1) / 2)) - local top_font, top_index = selectFittingFont(content_w, top_h, row1_measure) - local top_font_h = measureFont(top_font) + local top_font = font_tools.selectFont(top_h, content_w, row1_measure) + local top_font_h = font_tools.measureFont(top_font) local detail_h = math.max(1, content_h - top_font_h - row_gap) - -- The second font should be smaller than the first one, so start - -- looking from the next smaller font than the one used for row 1 - local detail_font = selectFittingFont(content_w, detail_h, row2_measure, math.min(top_index + 1, #FONT_SIZES)) - local detail_font_h = measureFont(detail_font) + local detail_font = font_tools.selectFont(detail_h, content_w, row2_measure, top_font) + local detail_font_h = font_tools.measureFont(detail_font) local detail_y = pad_y + top_font_h + row_gap children[#children + 1] = { @@ -255,8 +222,8 @@ local function showWidget(widget) align = LEFT } else - local row_font = selectFittingFont(content_w, content_h, row1_measure) - local row_font_h = measureFont(row_font) + local row_font = font_tools.selectFont(content_h, content_w, row1_measure) + local row_font_h = font_tools.measureFont(row_font) local row_y = pad_y + math.max(0, math.floor((content_h - row_font_h) / 2)) children[#children + 1] = { From e014525485bf5876310c0cd1bbd03eabc280523c Mon Sep 17 00:00:00 2001 From: gismo2004 Date: Sun, 31 May 2026 13:07:47 +0200 Subject: [PATCH 6/7] throw a warning if widget is used multiple times. Only one incstance of rf2 is allowed --- src/WIDGETS/RfTool/app.lua | 35 +++++++++++++++++++++++++++++++++-- src/WIDGETS/RfTool/main.lua | 4 ++-- 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/WIDGETS/RfTool/app.lua b/src/WIDGETS/RfTool/app.lua index f5cdee7..0e310a7 100644 --- a/src/WIDGETS/RfTool/app.lua +++ b/src/WIDGETS/RfTool/app.lua @@ -1,5 +1,7 @@ +---@diagnostic disable: undefined-global -- RfTool widget -local zone, options = ... +local zone, options, warning_duplicate = ... +warning_duplicate = warning_duplicate == true local w = { zone = zone, @@ -140,6 +142,7 @@ local function showWidget(widget) local row1_measure = nil local row2_text = nil local row2_measure = nil + local text_color = widget.options.TextColor or COLOR_THEME_PRIMARY1 -- Determine what text to show in row 1 and row 2 based on the options if show_model then @@ -177,6 +180,14 @@ local function showWidget(widget) end end + if warning_duplicate then + row1_text = "Warning" + row1_measure = row1_text + row2_text = "Use only one RfTool widget" + row2_measure = row2_text + text_color = COLOR_THEME_WARNING + end + local children = {} if row1_text then @@ -188,7 +199,6 @@ local function showWidget(widget) local row_gap = 1 local content_w = math.max(1, widget_w - 2 * pad_x) local content_h = math.max(1, widget_h - 2 * pad_y) - local text_color = widget.options.TextColor or COLOR_THEME_PRIMARY1 if row2_text then local top_h = math.max(1, math.floor((content_h - row_gap + 1) / 2)) @@ -256,6 +266,11 @@ w.update = function(widget, options) end end + if warning_duplicate then + showWidget(widget) + return + end + if lvgl.isFullScreen() or lvgl.isAppMode() then rf2.restartUi() else @@ -264,6 +279,10 @@ w.update = function(widget, options) end w.background = function(widget, calledFromRefresh) + if warning_duplicate then + return + end + if widget.state == "compiling" then compileTask = compileTask or assert(loadScript("/SCRIPTS/RF2/COMPILE/compile.lua"))() if compileTask() == 1 then @@ -304,6 +323,13 @@ end local redrawWidget = false w.refresh = function(widget, event, touchState) + if warning_duplicate then + if not widget.visible then + showWidget(widget) + end + return + end + if uiTask ~= nil then if redrawWidget or not widget.visible or widget.renderedModelName ~= getDisplayedModelName(widget) then -- If we immediately show the widget after lvgl.exitFullScreen(), the widget if briefly @@ -323,6 +349,11 @@ w.refresh = function(widget, event, touchState) w.background(widget, true) end +if warning_duplicate then + -- Duplicate instances stay warning-only so they do not reinitialize shared RF2 state. + return w +end + initializeRf2GlobalVar() rf2.registerWidget = registerWidget rf2.rfToolApiVersion = 1.00 diff --git a/src/WIDGETS/RfTool/main.lua b/src/WIDGETS/RfTool/main.lua index bd21b26..fa4e3c0 100644 --- a/src/WIDGETS/RfTool/main.lua +++ b/src/WIDGETS/RfTool/main.lua @@ -1,3 +1,4 @@ +---@diagnostic disable: undefined-global -- Keep main.lua as lightweight as possible, since main.lua gets loaded for **all** widgets at boot time. -- Even if a widget isn't used by a particular model. local name = "RF Tool" @@ -29,8 +30,7 @@ if lvgl == nil then end local function create(zone, options) - local widget = loadScript("/WIDGETS/RfTool/app.lua")(zone, options) - return widget + return loadScript("/WIDGETS/RfTool/app.lua")(zone, options, rf2 ~= nil) end local function update(widget, options) From f0f371ec20dd659409c7f3559e9e2f57c06757ce Mon Sep 17 00:00:00 2001 From: gismo2004 Date: Sun, 31 May 2026 13:56:50 +0200 Subject: [PATCH 7/7] we need a heart beat, as rf2 is not cleared between modelchanges. --- src/WIDGETS/RfTool/app.lua | 5 +++++ src/WIDGETS/RfTool/main.lua | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/WIDGETS/RfTool/app.lua b/src/WIDGETS/RfTool/app.lua index 0e310a7..3c9a8bf 100644 --- a/src/WIDGETS/RfTool/app.lua +++ b/src/WIDGETS/RfTool/app.lua @@ -283,6 +283,8 @@ w.background = function(widget, calledFromRefresh) return end + rf2.rfToolInstanceSeenAt = getTime() + if widget.state == "compiling" then compileTask = compileTask or assert(loadScript("/SCRIPTS/RF2/COMPILE/compile.lua"))() if compileTask() == 1 then @@ -330,6 +332,8 @@ w.refresh = function(widget, event, touchState) return end + rf2.rfToolInstanceSeenAt = getTime() + if uiTask ~= nil then if redrawWidget or not widget.visible or widget.renderedModelName ~= getDisplayedModelName(widget) then -- If we immediately show the widget after lvgl.exitFullScreen(), the widget if briefly @@ -355,6 +359,7 @@ if warning_duplicate then end initializeRf2GlobalVar() +rf2.rfToolInstanceSeenAt = getTime() rf2.registerWidget = registerWidget rf2.rfToolApiVersion = 1.00 diff --git a/src/WIDGETS/RfTool/main.lua b/src/WIDGETS/RfTool/main.lua index fa4e3c0..c5ca9c1 100644 --- a/src/WIDGETS/RfTool/main.lua +++ b/src/WIDGETS/RfTool/main.lua @@ -2,6 +2,7 @@ -- Keep main.lua as lightweight as possible, since main.lua gets loaded for **all** widgets at boot time. -- Even if a widget isn't used by a particular model. local name = "RF Tool" +local ACTIVE_WIDGET_TIMEOUT = 100 -- 1 second timeout --- this is for VSCode extnension 'EdgeTX Dev Kit' ---@type WidgetScript @@ -30,7 +31,10 @@ if lvgl == nil then end local function create(zone, options) - return loadScript("/WIDGETS/RfTool/app.lua")(zone, options, rf2 ~= nil) + local now = getTime() + local warning_duplicate = rf2 ~= nil and rf2.rfToolInstanceSeenAt ~= nil and now - rf2.rfToolInstanceSeenAt <= ACTIVE_WIDGET_TIMEOUT + + return loadScript("/WIDGETS/RfTool/app.lua")(zone, options, warning_duplicate) end local function update(widget, options)